Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
Expand Down Expand Up @@ -73,13 +74,17 @@ public class ColumnDefinitions implements Iterable<ColumnDefinitions.Definition>
this.byName = new HashMap<String, int[]>(defs.length);

for (int i = 0; i < defs.length; i++) {
// Fold with ROOT rather than the default locale: a name such as IN(v) would be indexed as
// ın(v) in a Turkish JVM, and no lookup for in(v) could then find it. Computed once so that
// the two puts below cannot drift apart.
String key = defs[i].name.toLowerCase(Locale.ROOT);
// Be optimistic, 99% of the time, previous will be null.
int[] previous = this.byName.put(defs[i].name.toLowerCase(), new int[] {i});
int[] previous = this.byName.put(key, new int[] {i});
if (previous != null) {
int[] indexes = new int[previous.length + 1];
System.arraycopy(previous, 0, indexes, 0, previous.length);
indexes[indexes.length - 1] = i;
this.byName.put(defs[i].name.toLowerCase(), indexes);
this.byName.put(key, indexes);
}
}
}
Expand Down Expand Up @@ -247,14 +252,17 @@ int[] findAllIdx(String name) {
caseSensitive = true;
}

int[] indexes = byName.get(name.toLowerCase());
int[] indexes = byName.get(name.toLowerCase(Locale.ROOT));
if (!caseSensitive || indexes == null) return indexes;

// First, optimistic and assume all are matching
int nbMatch = 0;
for (int i = 0; i < indexes.length; i++) if (name.equals(byIdx[indexes[i]].name)) nbMatch++;

if (nbMatch == indexes.length) return indexes;
// Report the name as absent rather than returning an empty array: callers distinguish "no such
// name" by a null return, and an unquoted name that matches nothing already lands there.
if (nbMatch == 0) return null;

int[] result = new int[nbMatch];
int j = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

Expand Down Expand Up @@ -115,7 +116,10 @@ public boolean isCompatibleWith(Name that) {

@Override
public String toString() {
return super.toString().toLowerCase();
// ROOT, not the default locale: this string is spliced into generated CQL by the schema
// builder, so in a Turkish JVM INT would render as a dotless int and the server would reject
// the statement as a syntax error.
return super.toString().toLowerCase(Locale.ROOT);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
Expand Down Expand Up @@ -295,7 +296,9 @@ static String handleId(String id) {
return id;
}
if (isAlphanumeric) {
return id.toLowerCase();
// ROOT, not the default locale: this branch is only reached for ASCII-alphanumeric ids, which
// is exactly where a Turkish JVM would fold I to the dotless ı and make the id unmatchable.
return id.toLowerCase(Locale.ROOT);
Comment thread
nikagra marked this conversation as resolved.
}

// Check if it's enclosed in quotes. If it is, remove them and unescape internal double quotes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.nio.ByteBuffer;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
Expand All @@ -55,7 +56,10 @@ static String handleId(String id) {
// Shouldn't really happen for this method, but no reason to fail here
if (id == null) return null;

if (alphanumeric.matcher(id).matches()) return id.toLowerCase();
// ROOT, not the default locale: only ASCII-alphanumeric ids reach this branch, which is exactly
// where a Turkish JVM would fold I to the dotless ı. maybeAddRoutingKey compares the result
// against the partition key name, so a locale-dependent fold silently drops the routing key.
if (alphanumeric.matcher(id).matches()) return id.toLowerCase(Locale.ROOT);

// Check if it's enclosed in quotes. If it is, remove them and unescape internal double quotes
if (!id.isEmpty() && id.charAt(0) == '"' && id.charAt(id.length() - 1) == '"')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
*/
package com.datastax.driver.core;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;

import java.util.Locale;
import org.testng.annotations.Test;

public class ColumnDefinitionsTest {
Expand Down Expand Up @@ -78,4 +82,86 @@ public void multiDefinitionTest() {

assertTrue(defs.getType("column").equals(DataType.text()));
}

/**
* The variable definitions a server returns for "SELECT * FROM t WHERE pk = ? AND v IN ? AND v IN
* ?": each marker of an IN relation gets a name synthesized from the operator and the column, so
* repeating the column yields the same name twice. That spelling differs between ScyllaDB release
* lines rather than along a single version sequence: 2024.1 emits in(v), 2026.1.8 emits IN(v),
* and the lowercase spelling is restored in 2026.1.12 and 2026.2.6 (CUSTOMER-583 /
* SCYLLADB-3454). An application must therefore not depend on either spelling.
*/
private static ColumnDefinitions synthesizedInMarkerDefinitions() {
return new ColumnDefinitions(
new ColumnDefinitions.Definition[] {
new ColumnDefinitions.Definition("ks", "cf", "pk", DataType.cint()),
new ColumnDefinitions.Definition("ks", "cf", "IN(v)", DataType.list(DataType.cint())),
new ColumnDefinitions.Definition("ks", "cf", "IN(v)", DataType.list(DataType.cint())),
},
CodecRegistry.DEFAULT_INSTANCE);
}

@Test(groups = "unit")
public void synthesizedMarkerNameIsMatchedWhateverTheServerSpelling() {
ColumnDefinitions defs = synthesizedInMarkerDefinitions();

assertTrue(defs.contains("IN(v)"));
assertTrue(defs.contains("in(v)"));
assertTrue(defs.contains("In(V)"));
assertEquals(defs.getFirstIdx("in(v)"), 1);
}

/**
* The letter that flipped in CUSTOMER-583 is {@code I}, and lowercasing it in the Turkish locale
* yields a dotless {@code ı}. Matching must not depend on the JVM's default locale, or a Turkish
* deployment would fail to resolve the name that works everywhere else.
*/
@Test(groups = "unit")
public void synthesizedMarkerNameIsMatchedInAnyDefaultLocale() {
Locale def = Locale.getDefault();
try {
Locale.setDefault(new Locale("tr", "TR"));
ColumnDefinitions defs = synthesizedInMarkerDefinitions();
// Probe both spellings. The definitions are built inside the locale override, so the
// lowercase probe covers the fold applied while indexing; but "in(v)" is left alone by every
// locale, so it would not catch a lookup that stopped pinning ROOT — the uppercase probe
// covers that side.
assertTrue(defs.contains("in(v)"));
assertEquals(defs.getFirstIdx("in(v)"), 1);
assertTrue(defs.contains("IN(v)"));
assertEquals(defs.getFirstIdx("IN(v)"), 1);
} finally {
Locale.setDefault(def);
}
}

/** A named setter writes every matching variable, so repeating a column makes names ambiguous. */
@Test(groups = "unit")
public void synthesizedMarkerNameMatchesEveryOccurrence() {
assertEquals(synthesizedInMarkerDefinitions().getAllIdx("in(v)"), new int[] {1, 2});
}

/**
* Double-quoting opts into exact matching, which the synthesized spelling can then break. A name
* that survives the case-insensitive lookup but no exact comparison must be reported absent, the
* same way an unquoted name that matches nothing is — otherwise contains() claims the name is
* there, getIndexOf() throws instead of returning -1, and a setter silently leaves the variable
* unset, which the server then rejects with "Unexpected unset value for bind variable N".
*/
@Test(groups = "unit")
public void doubleQuotedSynthesizedMarkerNameOfDifferentCaseIsNotMatched() {
ColumnDefinitions defs = synthesizedInMarkerDefinitions();

assertTrue(defs.contains("\"IN(v)\""));
assertEquals(defs.getIndexOf("\"IN(v)\""), 1);

assertFalse(defs.contains("\"in(v)\""));
assertEquals(defs.getIndexOf("\"in(v)\""), -1);
try {
defs.getType("\"in(v)\"");
fail("expected an IllegalArgumentException");
} catch (IllegalArgumentException e) {
// expected
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
Expand Down Expand Up @@ -407,4 +408,28 @@ public void serializeDeserializeCollectionsTest(ProtocolVersion version) {
/* That's what we want */
}
}

/**
* {@code Name.toString()} lower-cases the enum constant, and the schema builder splices the
* result straight into generated CQL — {@code Alter.type()} and {@code NativeColumnType}, so
* every ALTER TYPE, ADD column and CREATE TABLE column. Every type name holding an I is affected,
* so an unpinned fold makes a Turkish JVM emit {@code TYPE ınt} and the server reject the
* statement.
*/
@Test(groups = "unit")
public void toStringIsIndependentOfDefaultLocaleTest() {
Locale def = Locale.getDefault();
try {
Locale.setDefault(new Locale("tr", "TR"));
assertThat(DataType.cint().toString()).isEqualTo("int");
assertThat(DataType.ascii().toString()).isEqualTo("ascii");
assertThat(DataType.timestamp().toString()).isEqualTo("timestamp");
// The collection name and its arguments each fold separately.
assertThat(DataType.list(DataType.cint()).toString()).isEqualTo("list<int>");
assertThat(DataType.map(DataType.text(), DataType.timestamp()).toString())
.isEqualTo("map<text, timestamp>");
} finally {
Locale.setDefault(def);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import static com.datastax.driver.core.TestUtils.waitForUp;

import com.google.common.collect.Maps;
import java.util.Locale;
import java.util.Map;
import org.testng.annotations.Test;

Expand Down Expand Up @@ -127,6 +128,23 @@ public void handleId_should_lowercase_unquoted_alphanumeric_identifiers() {
assertThat(Metadata.handleId("foo_bar_1")).isEqualTo("foo_bar_1");
}

/**
* Identifier folding must not depend on the JVM's default locale: in a Turkish locale {@code I}
* lowercases to the dotless {@code ı}, so {@code getTable("ID_TABLE")} would look up {@code
* ıd_table} and never match the {@code id_table} the server reported.
*/
@Test(groups = "unit")
public void handleId_should_lowercase_unquoted_alphanumeric_identifiers_in_any_default_locale() {
Locale def = Locale.getDefault();
try {
Locale.setDefault(new Locale("tr", "TR"));
assertThat(Metadata.handleId("ID_TABLE")).isEqualTo("id_table");
assertThat(Metadata.handleId("FooBar1")).isEqualTo("foobar1");
} finally {
Locale.setDefault(def);
}
}

@Test(groups = "unit")
public void handleId_should_unquote_and_preserve_case_of_quoted_identifiers() {
assertThat(Metadata.handleId("\"FooBar1\"")).isEqualTo("FooBar1");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@
import com.google.common.util.concurrent.Uninterruptibles;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.testng.SkipException;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.Test;

Expand Down Expand Up @@ -865,4 +868,66 @@ public void should_propagate_idempotence_in_statements() {

assertThat(bound.isIdempotent()).isTrue();
}

/**
* The driver resolves prepared-statement variable names locally, so it must not depend on the
* name the server synthesizes for an anonymous marker. That name differs between ScyllaDB release
* lines rather than along a single version sequence: 2024.1 spells the marker of an IN relation
* {@code in(k)}, 2026.1.8 spells it {@code IN(k)}, and the lowercase spelling is restored in
* 2026.1.12 and 2026.2.6 (CUSTOMER-583 / SCYLLADB-3454); Apache Cassandra spells it {@code
* in(k)}. This test therefore reads the name back from the metadata instead of hardcoding a
* spelling, and asserts that both cases of it resolve to the same variable — as well as the
* positional binding that the manual recommends applications use instead.
*/
@Test(groups = "short")
public void should_bind_anonymous_in_marker_by_position_and_by_either_synthesized_case() {
for (int i = 1; i <= 3; i++) {
session()
.execute(String.format("INSERT INTO %s (k, i) VALUES ('key%d', %d)", SIMPLE_TABLE, i, i));
}

PreparedStatement ps = session().prepare("SELECT i FROM " + SIMPLE_TABLE + " WHERE k IN ?");

ColumnDefinitions variables = ps.getVariables();
assertThat(variables.size()).isEqualTo(1);
String synthesized = variables.getName(0);

// Skip rather than fail if the server ever names the marker plainly "k": everything below still
// passes then, but it no longer covers the synthesized-name mechanism at all, and the spelling
// is precisely what this test refuses to treat as a contract.
if ("k".equals(synthesized) || !synthesized.contains("(")) {
throw new SkipException(
"server named the marker " + synthesized + ", so there is no synthesized name to cover");
}

// Whatever the server sent, the case of the name must not decide whether it resolves.
assertThat(variables.getIndexOf(synthesized)).isEqualTo(0);
assertThat(variables.getIndexOf(synthesized.toLowerCase(Locale.ROOT))).isEqualTo(0);
assertThat(variables.getIndexOf(synthesized.toUpperCase(Locale.ROOT))).isEqualTo(0);

List<String> keys = Arrays.asList("key1", "key3");

// What applications should do: fill anonymous markers by position.
assertThat(selectedInts(ps.bind().setList(0, keys))).containsOnly(1, 3);

// What CUSTOMER-583 did. It works here because the name setters ignore case, but the manual
// steers applications away from it: the spelling is not part of any contract.
for (String name :
Arrays.asList(
synthesized,
synthesized.toLowerCase(Locale.ROOT),
synthesized.toUpperCase(Locale.ROOT))) {
assertThat(selectedInts(ps.bind().setList(name, keys)))
.as("bound by name " + name)
.containsOnly(1, 3);
}
}

private List<Integer> selectedInts(BoundStatement bound) {
List<Integer> values = new ArrayList<Integer>();
for (Row row : session().execute(bound)) {
values.add(row.getInt("i"));
}
return values;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
Expand Down Expand Up @@ -1637,6 +1638,27 @@ public void should_handle_allow_filtering() {
.isEqualTo("SELECT * FROM foo WHERE x=42 ALLOW FILTERING;");
}

/**
* The query builder folds a column name before comparing it with the partition key name, in
* {@code BuiltStatement.maybeAddRoutingKey}. That fold must not depend on the JVM's default
* locale: in a Turkish locale {@code I} lowercases to the dotless {@code ı}, so a clause on
* {@code ID} would stop matching a partition key called {@code id} and the statement would
* silently lose its routing key, costing it token-aware routing.
*
* @test_category queries:builder
*/
@Test(groups = "unit")
public void should_handle_id_in_any_default_locale() {
Locale def = Locale.getDefault();
try {
Locale.setDefault(new Locale("tr", "TR"));
assertThat(Utils.handleId("ID")).isEqualTo("id");
assertThat(Utils.handleId("Id_1")).isEqualTo("id_1");
} finally {
Locale.setDefault(def);
}
}

/** @test_category queries:builder */
@Test(groups = "unit")
public void should_handle_bypass_cache() {
Expand Down
Loading
Loading