Skip to content

Commit 8e6f5a4

Browse files
authored
Merge pull request #16073 from cdapio/spanner-query-fix
[CDAP-21220] Fix range Where clause in the SpannerStructuredTable queries
2 parents 09f11b6 + e6be0cd commit 8e6f5a4

File tree

3 files changed

+197
-28
lines changed

3 files changed

+197
-28
lines changed

cdap-storage-ext-spanner/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@
8282
<artifactId>snappy-java</artifactId>
8383
<version>1.1.10.7</version>
8484
</dependency>
85+
<dependency>
86+
<groupId>pl.pragmatists</groupId>
87+
<artifactId>JUnitParams</artifactId>
88+
<scope>test</scope>
89+
</dependency>
8590
</dependencies>
8691

8792
<profiles>

cdap-storage-ext-spanner/src/main/java/io/cdap/cdap/storage/spanner/SpannerStructuredTable.java

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import com.google.cloud.spanner.TimestampBound;
3030
import com.google.cloud.spanner.TransactionContext;
3131
import com.google.cloud.spanner.Value;
32+
import com.google.common.annotations.VisibleForTesting;
3233
import io.cdap.cdap.api.dataset.lib.AbstractCloseableIterator;
3334
import io.cdap.cdap.api.dataset.lib.CloseableIterator;
3435
import io.cdap.cdap.spi.data.FieldSizeLimitExceededException;
@@ -545,39 +546,82 @@ private String getRangeWhereClause(Range range, Map<String, Value> parameters) {
545546
List<String> conditions = new ArrayList<>();
546547
int paramIndex = parameters.size();
547548

548-
int beginIndex = 1;
549-
int beginFieldsCount = range.getBegin().size();
550-
for (Field<?> field : range.getBegin()) {
551-
// Edge case for = when we filter by the last field
552-
// This is for case like ([KEY: "ns1", KEY2: 123], EXCLUSIVE, [KEY: "ns1", KEY2: 250], INCLUSIVE)
553-
// We need to check the ending bound for KEY2, not KEY
554-
String symbol =
555-
beginIndex == beginFieldsCount && range.getBeginBound().equals(Range.Bound.EXCLUSIVE)
556-
? " > " : " >= ";
557-
conditions.add(escapeName(field.getName()) + symbol + "@p_" + paramIndex);
558-
parameters.put("p_" + paramIndex, getValue(field));
559-
paramIndex++;
560-
beginIndex++;
561-
}
562-
563-
int endIndex = 1;
564-
int endFieldsCount = range.getEnd().size();
565-
for (Field<?> field : range.getEnd()) {
566-
// Edge case for = when we filter by the last field
567-
// This is for case like ([KEY: "ns1", KEY2: 123], INCLUSIVE, [KEY: "ns1", KEY2: 250], EXCLUSIVE)
568-
// We need to check the ending bound for KEY2, not KEY
569-
String symbol =
570-
endIndex == endFieldsCount && range.getEndBound().equals(Range.Bound.EXCLUSIVE) ? " < "
571-
: " <= ";
572-
conditions.add(escapeName(field.getName()) + symbol + "@p_" + paramIndex);
573-
parameters.put("p_" + paramIndex, getValue(field));
574-
paramIndex++;
575-
endIndex++;
549+
if (!range.getBegin().isEmpty()) {
550+
conditions.add(getCompositeKeyCondition(new ArrayList<>(range.getBegin()), range.getBeginBound(),
551+
true, parameters, paramIndex));
552+
paramIndex += range.getBegin().size();
553+
}
554+
555+
if (!range.getEnd().isEmpty()) {
556+
conditions.add(getCompositeKeyCondition(new ArrayList<>(range.getEnd()), range.getEndBound(),
557+
false, parameters, paramIndex));
576558
}
577559

578560
return String.join(" AND ", conditions);
579561
}
580562

563+
/**
564+
* Builds a SQL fragment for one bound (lower or upper) of a range on a compound key,
565+
* handling lexicographical ordering.
566+
*
567+
* <p>Example: For a lower bound `(Key1, Key2) >= ('A', 10)`, this generates:
568+
* `((`Key1` > @p_0) OR (`Key1` = @p_0 AND `Key2` >= @p_1))`.</p>
569+
* The parameters map will contain mappings for `p_0` to 'A' and `p_1` to 10.
570+
*
571+
* @param fields The fields forming the compound key (e.g., namespace, application, version).
572+
* @param bound The bound type (INCLUSIVE or EXCLUSIVE).
573+
* @param isLowerBound True if building the condition for the beginning of the range (lower bound),
574+
* false if for the end of the range (upper bound).
575+
* @param parameters The map to add Spanner parameters to. Parameter names will be generated
576+
* starting from the current size of this map.
577+
* @return A SQL fragment for the composite range bound.
578+
*/
579+
@VisibleForTesting
580+
String getCompositeKeyCondition(List<Field<?>> fields, Range.Bound bound, boolean isLowerBound,
581+
Map<String, Value> parameters, int startParamIndex) {
582+
if (fields.isEmpty()) {
583+
return "";
584+
}
585+
586+
List<String> orClauses = new ArrayList<>();
587+
StringBuilder equalityPrefix = new StringBuilder();
588+
589+
for (int i = 0; i < fields.size(); i++) {
590+
Field<?> field = fields.get(i);
591+
String paramName = "p_" + (startParamIndex + i);
592+
parameters.put(paramName, getValue(field));
593+
594+
// 1. Determine operator: strictly >/< for non-terminal fields,
595+
// or based on inclusivity for the final field.
596+
boolean isLast = (i == fields.size() - 1);
597+
boolean isInclusive = bound == Range.Bound.INCLUSIVE;
598+
String op;
599+
if (isLowerBound) {
600+
op = (isLast && isInclusive) ? " >= " : " > ";
601+
} else {
602+
op = (isLast && isInclusive) ? " <= " : " < ";
603+
}
604+
605+
// 2. Construct the OR clause for this depth
606+
String escapedName = escapeName(field.getName());
607+
String condition = escapedName + op + "@" + paramName;
608+
if (equalityPrefix.length() > 0) {
609+
orClauses.add("(" + equalityPrefix + " AND " + condition + ")");
610+
} else {
611+
orClauses.add("(" + condition + ")");
612+
}
613+
614+
if (!isLast) {
615+
if (equalityPrefix.length() > 0) {
616+
equalityPrefix.append(" AND ");
617+
}
618+
equalityPrefix.append(escapedName).append(" = @").append(paramName);
619+
}
620+
}
621+
622+
return "(" + String.join(" OR ", orClauses) + ")";
623+
}
624+
581625
private String getFieldsWhereClause(Collection<Field<?>> fields, Map<String, Value> parameters) {
582626
List<String> conditions = new ArrayList<>();
583627
int paramIndex = parameters.size();

cdap-storage-ext-spanner/src/test/java/io/cdap/cdap/storage/spanner/SpannerStructuredTableTest.java

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,32 @@
1616

1717
package io.cdap.cdap.storage.spanner;
1818

19+
import com.google.cloud.spanner.Value;
1920
import io.cdap.cdap.api.metrics.MetricsCollector;
2021
import io.cdap.cdap.spi.data.StorageProviderContext;
2122
import io.cdap.cdap.spi.data.StructuredTable;
2223
import io.cdap.cdap.spi.data.StructuredTableAdmin;
2324
import io.cdap.cdap.spi.data.StructuredTableTest;
25+
import io.cdap.cdap.spi.data.table.field.Field;
26+
import io.cdap.cdap.spi.data.table.field.Fields;
27+
import io.cdap.cdap.spi.data.table.field.Range;
2428
import io.cdap.cdap.spi.data.transaction.TransactionRunner;
29+
import java.util.Arrays;
2530
import java.util.Collections;
2631
import java.util.HashMap;
32+
import java.util.List;
2733
import java.util.Map;
2834
import java.util.Optional;
35+
import junitparams.JUnitParamsRunner;
36+
import junitparams.Parameters;
37+
import junitparams.naming.TestCaseName;
2938
import org.junit.AfterClass;
39+
import org.junit.Assert;
3040
import org.junit.Assume;
3141
import org.junit.BeforeClass;
3242
import org.junit.Ignore;
43+
import org.junit.Test;
44+
import org.junit.runner.RunWith;
3345

3446
/**
3547
* Unit tests for GCP spanner implementation of the {@link StructuredTable}. This test needs the following
@@ -43,6 +55,7 @@
4355
* json that has the "Cloud Spanner Database User" role</li>
4456
* </ul>
4557
*/
58+
@RunWith(JUnitParamsRunner.class)
4659
public class SpannerStructuredTableTest extends StructuredTableTest {
4760

4861
private static SpannerStorageProvider storageProvider;
@@ -93,6 +106,113 @@ protected TransactionRunner getTransactionRunner() {
93106
return storageProvider.getTransactionRunner();
94107
}
95108

109+
@Test
110+
@Parameters(method = "getCompositeKeyConditionScenarios")
111+
@TestCaseName("{0}")
112+
public void testGetCompositeKeyCondition(String description, List<Field<?>> fields,
113+
Range.Bound bound, boolean isLowerBound, int startParamIndex, String expectedSql,
114+
Map<String, Value> expectedParams) {
115+
SpannerStructuredTableSchema schema = new SpannerStructuredTableSchema(SIMPLE_SPEC.getTableId(),
116+
SIMPLE_SPEC.getFieldTypes(), SIMPLE_SPEC.getPrimaryKeys(), SIMPLE_SPEC.getIndexes(), null);
117+
try (SpannerStructuredTable table = new SpannerStructuredTable(null, schema, null)) {
118+
Map<String, Value> actualParams = new HashMap<>();
119+
String actualSql = table.getCompositeKeyCondition(fields, bound, isLowerBound, actualParams,
120+
startParamIndex);
121+
Assert.assertEquals("SQL mismatch in: " + description, expectedSql, actualSql);
122+
Assert.assertEquals("Params mismatch in: " + description, expectedParams, actualParams);
123+
}
124+
}
125+
126+
private Object[] getCompositeKeyConditionScenarios() {
127+
return new Object[]{
128+
// Format: Name | Fields | Bound Type | isLower | startParamIndex | Expected SQL | Expected Params
129+
new Object[]{
130+
"COMPOSITE: (col1, col2) >= ('A', 5) [Lower Inclusive]",
131+
Arrays.asList(Fields.stringField("col1", "A"), Fields.intField("col2", 5)),
132+
Range.Bound.INCLUSIVE, true, 0,
133+
"((`col1` > @p_0) OR (`col1` = @p_0 AND `col2` >= @p_1))",
134+
params("p_0", Value.string("A"), "p_1", Value.int64(5))
135+
},
136+
new Object[]{
137+
"COMPOSITE: (col1, col2) > ('A', 5) [Lower Exclusive]",
138+
Arrays.asList(Fields.stringField("col1", "A"), Fields.intField("col2", 5)),
139+
Range.Bound.EXCLUSIVE, true, 0,
140+
"((`col1` > @p_0) OR (`col1` = @p_0 AND `col2` > @p_1))",
141+
params("p_0", Value.string("A"), "p_1", Value.int64(5))
142+
},
143+
new Object[]{
144+
"COMPOSITE: (col1, col2) <= ('B', 10) [Upper Inclusive]",
145+
Arrays.asList(Fields.stringField("col1", "B"), Fields.intField("col2", 10)),
146+
Range.Bound.INCLUSIVE, false, 0,
147+
"((`col1` < @p_0) OR (`col1` = @p_0 AND `col2` <= @p_1))",
148+
params("p_0", Value.string("B"), "p_1", Value.int64(10))
149+
},
150+
151+
new Object[]{
152+
"SINGLE: col1 >= 'A' [Lower Inclusive]",
153+
Collections.singletonList(Fields.stringField("col1", "A")),
154+
Range.Bound.INCLUSIVE, true, 0,
155+
"((`col1` >= @p_0))",
156+
params("p_0", Value.string("A"))
157+
},
158+
new Object[]{
159+
"SINGLE: col1 < 'Z' [Upper Exclusive]",
160+
Collections.singletonList(Fields.stringField("col1", "Z")),
161+
Range.Bound.EXCLUSIVE, false, 0,
162+
"((`col1` < @p_0))",
163+
params("p_0", Value.string("Z"))
164+
},
165+
166+
new Object[]{
167+
"DEEP: (k1, k2, k3, k4) <= ('x', 1, true, 99) [Upper Inclusive]",
168+
Arrays.asList(
169+
Fields.stringField("k1", "x"),
170+
Fields.intField("k2", 1),
171+
Fields.booleanField("k3", true),
172+
Fields.longField("k4", 99L)
173+
),
174+
Range.Bound.INCLUSIVE, false, 0,
175+
"((`k1` < @p_0) OR " +
176+
"(`k1` = @p_0 AND `k2` < @p_1) OR " +
177+
"(`k1` = @p_0 AND `k2` = @p_1 AND `k3` < @p_2) OR " +
178+
"(`k1` = @p_0 AND `k2` = @p_1 AND `k3` = @p_2 AND `k4` <= @p_3))",
179+
params("p_0", Value.string("x"), "p_1", Value.int64(1), "p_2", Value.bool(true), "p_3",
180+
Value.int64(99L))
181+
},
182+
183+
new Object[]{
184+
"EDGE: Empty field list returns empty string",
185+
Collections.emptyList(),
186+
Range.Bound.INCLUSIVE, true, 0,
187+
"",
188+
Collections.emptyMap()
189+
},
190+
191+
new Object[]{
192+
"OFFSET: 1 Field, Lower Inclusive, startParamIndex = 5",
193+
Collections.singletonList(Fields.stringField("id", "123")),
194+
Range.Bound.INCLUSIVE, true, 5,
195+
"((`id` >= @p_5))",
196+
params("p_5", Value.string("123"))
197+
},
198+
new Object[]{
199+
"OFFSET: 2 Fields, Upper Exclusive, startParamIndex = 10",
200+
Arrays.asList(Fields.stringField("col1", "X"), Fields.intField("col2", 99)),
201+
Range.Bound.EXCLUSIVE, false, 10,
202+
"((`col1` < @p_10) OR (`col1` = @p_10 AND `col2` < @p_11))",
203+
params("p_10", Value.string("X"), "p_11", Value.int64(99))
204+
}
205+
};
206+
}
207+
208+
private static Map<String, Value> params(Object... entries) {
209+
Map<String, Value> map = new HashMap<>();
210+
for (int i = 0; i < entries.length; i += 2) {
211+
map.put((String) entries[i], (Value) entries[i + 1]);
212+
}
213+
return map;
214+
}
215+
96216
private static final class MockStorageProviderContext implements StorageProviderContext {
97217

98218
private final Map<String, String> config;

0 commit comments

Comments
 (0)