Skip to content

Commit b8c5eb9

Browse files
perf: Optimize ResultSet column lookup with HashMap-based indexing (#138)
This PR optimizes column lookup in ResultSet by replacing the existing linear search with a HashMap-based indexing strategy. The change significantly improves performance especially for wide result sets or repeated lookups while preserving full JDBC compatibility. - Replace O(n) linear search with O(1) HashMap lookup in findColumn() - Implement two-pass indexing: exact labels and lowercase labels - Exact matches are preferred over case-insensitive matches - First occurrence (lowest ordinal) wins for case-insensitive lookups - Override getter methods (getString, getInt, etc.) to use optimized findColumn()
1 parent 2ca44be commit b8c5eb9

File tree

7 files changed

+754
-2
lines changed

7 files changed

+754
-2
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the
3+
* Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt
4+
*/
5+
package com.salesforce.datacloud.jdbc.core;
6+
7+
import java.sql.SQLException;
8+
import java.util.HashMap;
9+
import java.util.List;
10+
import java.util.Map;
11+
import org.apache.calcite.avatica.ColumnMetaData;
12+
13+
/**
14+
* Resolves column names to their JDBC indices using fast HashMap lookups.
15+
*
16+
* <p>This class provides O(1) column name lookup instead of O(n) linear search, which is critical
17+
* for performance when dealing with large numbers of columns.
18+
*
19+
* <p>Lookup strategy:
20+
* <ol>
21+
* <li>First try exact match (case-sensitive)
22+
* <li>If no exact match, try lowercase match (case-insensitive fallback)
23+
* </ol>
24+
*
25+
* <p>This ensures exact matches are preferred, and case-insensitive matches use the first occurrence
26+
* (lowest ordinal) as a tie-breaker. For duplicate column names, the first column with that name
27+
* (lowest ordinal) is returned.
28+
*/
29+
public final class ColumnNameResolver {
30+
private final Map<String, Integer> exactColumnLabelMap;
31+
private final Map<String, Integer> lowercaseColumnLabelMap;
32+
33+
/**
34+
* Creates a new ColumnNameResolver from the given column metadata.
35+
*
36+
* @param columns the column metadata list
37+
*/
38+
public ColumnNameResolver(List<ColumnMetaData> columns) {
39+
this.exactColumnLabelMap = new HashMap<>(columns.size());
40+
this.lowercaseColumnLabelMap = new HashMap<>(columns.size());
41+
42+
// First pass: index exact labels to ordinals
43+
for (ColumnMetaData columnMetaData : columns) {
44+
if (columnMetaData.label != null) {
45+
// Use putIfAbsent to ensure first occurrence (lowest ordinal) wins for duplicates
46+
exactColumnLabelMap.putIfAbsent(columnMetaData.label, columnMetaData.ordinal);
47+
}
48+
}
49+
50+
// Second pass: index lowercase labels to ordinals, but only if the lowercase key doesn't exist yet
51+
// This ensures the first column with a given lowercase name wins (lowest ordinal)
52+
for (ColumnMetaData columnMetaData : columns) {
53+
if (columnMetaData.label != null) {
54+
String lowerLabel = columnMetaData.label.toLowerCase();
55+
// Only add if this lowercase key hasn't been seen yet (preserves first occurrence)
56+
lowercaseColumnLabelMap.putIfAbsent(lowerLabel, columnMetaData.ordinal);
57+
}
58+
}
59+
}
60+
61+
/**
62+
* Finds the JDBC column index (1-based) for the given column label.
63+
*
64+
* <p>First tries an exact case-sensitive match, then falls back to a case-insensitive match.
65+
*
66+
* @param columnLabel the column label to find
67+
* @return the JDBC column index (1-based)
68+
* @throws SQLException if the column is not found
69+
*/
70+
public int findColumn(String columnLabel) throws SQLException {
71+
// First try exact match (case-sensitive)
72+
Integer index = exactColumnLabelMap.get(columnLabel);
73+
if (index != null) {
74+
// Avatica uses 0-based ordinals, but JDBC uses 1-based indices
75+
return index + 1;
76+
}
77+
78+
// Fallback to lowercase match (case-insensitive)
79+
index = lowercaseColumnLabelMap.get(columnLabel.toLowerCase());
80+
if (index != null) {
81+
// Avatica uses 0-based ordinals, but JDBC uses 1-based indices
82+
return index + 1;
83+
}
84+
85+
throw new SQLException("column '" + columnLabel + "' not found", "42703");
86+
}
87+
}

jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataResultSet.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import org.apache.calcite.avatica.QueryState;
2020

2121
public class MetadataResultSet extends AvaticaResultSet {
22+
private final ColumnNameResolver columnNameResolver;
23+
2224
public MetadataResultSet(
2325
AvaticaStatement statement,
2426
QueryState state,
@@ -28,6 +30,7 @@ public MetadataResultSet(
2830
Meta.Frame firstFrame)
2931
throws SQLException {
3032
super(statement, state, signature, resultSetMetaData, timeZone, firstFrame);
33+
this.columnNameResolver = new ColumnNameResolver(signature.columns);
3134
}
3235

3336
public static AvaticaResultSet of() throws SQLException {
@@ -72,4 +75,61 @@ public int getConcurrency() {
7275
public int getFetchDirection() {
7376
return ResultSet.FETCH_FORWARD;
7477
}
78+
79+
@Override
80+
public int findColumn(String columnLabel) throws SQLException {
81+
return columnNameResolver.findColumn(columnLabel);
82+
}
83+
84+
/**
85+
* Override getter methods that take String columnLabel to ensure they use our optimized findColumn() method.
86+
* This is necessary because Avatica's implementation might use a private method or cache.
87+
*/
88+
@Override
89+
public String getString(String columnLabel) throws SQLException {
90+
int columnIndex = findColumn(columnLabel);
91+
return getString(columnIndex);
92+
}
93+
94+
@Override
95+
public int getInt(String columnLabel) throws SQLException {
96+
int columnIndex = findColumn(columnLabel);
97+
return getInt(columnIndex);
98+
}
99+
100+
@Override
101+
public long getLong(String columnLabel) throws SQLException {
102+
int columnIndex = findColumn(columnLabel);
103+
return getLong(columnIndex);
104+
}
105+
106+
@Override
107+
public boolean getBoolean(String columnLabel) throws SQLException {
108+
int columnIndex = findColumn(columnLabel);
109+
return getBoolean(columnIndex);
110+
}
111+
112+
@Override
113+
public byte getByte(String columnLabel) throws SQLException {
114+
int columnIndex = findColumn(columnLabel);
115+
return getByte(columnIndex);
116+
}
117+
118+
@Override
119+
public short getShort(String columnLabel) throws SQLException {
120+
int columnIndex = findColumn(columnLabel);
121+
return getShort(columnIndex);
122+
}
123+
124+
@Override
125+
public float getFloat(String columnLabel) throws SQLException {
126+
int columnIndex = findColumn(columnLabel);
127+
return getFloat(columnIndex);
128+
}
129+
130+
@Override
131+
public double getDouble(String columnLabel) throws SQLException {
132+
int columnIndex = findColumn(columnLabel);
133+
return getDouble(columnIndex);
134+
}
75135
}

jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public class StreamingResultSet extends AvaticaResultSet implements DataCloudRes
3131

3232
private final ArrowStreamReaderCursor cursor;
3333
ThrowingJdbcSupplier<QueryStatus> getQueryStatus;
34+
private final ColumnNameResolver columnNameResolver;
3435

3536
private StreamingResultSet(
3637
ArrowStreamReaderCursor cursor,
@@ -45,6 +46,7 @@ private StreamingResultSet(
4546
super(statement, state, signature, resultSetMetaData, timeZone, firstFrame);
4647
this.cursor = cursor;
4748
this.queryId = queryId;
49+
this.columnNameResolver = new ColumnNameResolver(signature.columns);
4850
}
4951

5052
public static StreamingResultSet of(ArrowStreamReader resultStream, String queryId) throws SQLException {
@@ -85,4 +87,61 @@ public int getFetchDirection() {
8587
public int getRow() {
8688
return cursor.getRowsSeen();
8789
}
90+
91+
@Override
92+
public int findColumn(String columnLabel) throws SQLException {
93+
return columnNameResolver.findColumn(columnLabel);
94+
}
95+
96+
/**
97+
* Override getter methods that take String columnLabel to ensure they use our optimized findColumn() method.
98+
* This is necessary because Avatica's implementation might use a private method or cache.
99+
*/
100+
@Override
101+
public String getString(String columnLabel) throws SQLException {
102+
int columnIndex = findColumn(columnLabel);
103+
return getString(columnIndex);
104+
}
105+
106+
@Override
107+
public int getInt(String columnLabel) throws SQLException {
108+
int columnIndex = findColumn(columnLabel);
109+
return getInt(columnIndex);
110+
}
111+
112+
@Override
113+
public long getLong(String columnLabel) throws SQLException {
114+
int columnIndex = findColumn(columnLabel);
115+
return getLong(columnIndex);
116+
}
117+
118+
@Override
119+
public boolean getBoolean(String columnLabel) throws SQLException {
120+
int columnIndex = findColumn(columnLabel);
121+
return getBoolean(columnIndex);
122+
}
123+
124+
@Override
125+
public byte getByte(String columnLabel) throws SQLException {
126+
int columnIndex = findColumn(columnLabel);
127+
return getByte(columnIndex);
128+
}
129+
130+
@Override
131+
public short getShort(String columnLabel) throws SQLException {
132+
int columnIndex = findColumn(columnLabel);
133+
return getShort(columnIndex);
134+
}
135+
136+
@Override
137+
public float getFloat(String columnLabel) throws SQLException {
138+
int columnIndex = findColumn(columnLabel);
139+
return getFloat(columnIndex);
140+
}
141+
142+
@Override
143+
public double getDouble(String columnLabel) throws SQLException {
144+
int columnIndex = findColumn(columnLabel);
145+
return getDouble(columnIndex);
146+
}
88147
}

0 commit comments

Comments
 (0)