From e71b691a10ab202f2ae4ada7e7494ef03fc83c1c Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Wed, 26 Aug 2026 15:12:09 +0200 Subject: [PATCH 1/4] fix: harden bind-variable name lookup in ColumnDefinitions (DRIVER-903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRIVER-903's coverage turned up two defects on the bind-by-name path, both reachable through the synthesized names CUSTOMER-583 is about. Lowercasing used the JVM's default locale on both sides, and the letter CUSTOMER-583 flipped is exactly `I`: a Turkish JVM indexes IN(v) as ın(v), so the in(v) an application binds no longer resolves. 4.x pins Locale.ROOT here; 3.x now does too. Second, a case-sensitive miss returned a zero-length array where callers read null as "no such name" — so contains() reported the name present, getIndexOf() threw instead of returning -1, and a name setter left the variable unset. Co-Authored-By: Claude Opus 5 (1M context) --- .../driver/core/ColumnDefinitions.java | 14 ++- .../driver/core/ColumnDefinitionsTest.java | 86 +++++++++++++++++++ upgrade_guide/README.md | 21 +++++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/driver-core/src/main/java/com/datastax/driver/core/ColumnDefinitions.java b/driver-core/src/main/java/com/datastax/driver/core/ColumnDefinitions.java index e2ed07fe16c..d4b91c7c77f 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/ColumnDefinitions.java +++ b/driver-core/src/main/java/com/datastax/driver/core/ColumnDefinitions.java @@ -19,6 +19,7 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -73,13 +74,17 @@ public class ColumnDefinitions implements Iterable this.byName = new HashMap(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); } } } @@ -247,7 +252,7 @@ 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 @@ -255,6 +260,9 @@ int[] findAllIdx(String name) { 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; diff --git a/driver-core/src/test/java/com/datastax/driver/core/ColumnDefinitionsTest.java b/driver-core/src/test/java/com/datastax/driver/core/ColumnDefinitionsTest.java index 88da5cbd578..e15dee0e8e0 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/ColumnDefinitionsTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/ColumnDefinitionsTest.java @@ -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 { @@ -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 + } + } } diff --git a/upgrade_guide/README.md b/upgrade_guide/README.md index 6a413a7f547..2c66c23dcb9 100644 --- a/upgrade_guide/README.md +++ b/upgrade_guide/README.md @@ -3,6 +3,27 @@ The purpose of this guide is to detail changes made by successive versions of the Java driver. +### 3.11.5.19 + +1. `ColumnDefinitions` now reports a double-quoted name whose case matches no definition as absent, + rather than as present with no indexes. This affects only a name that survives the + case-insensitive lookup but matches nothing exactly, for example `"\"in(v)\""` against a + definition named `IN(v)`. For such a name: + + * `contains()` returns `false` where it returned `true`; + * `getIndexOf()` returns `-1` where it threw `ArrayIndexOutOfBoundsException`; + * the name-based getters and setters on `Row` and `BoundStatement` throw + `IllegalArgumentException`, where a getter threw `ArrayIndexOutOfBoundsException` and a setter + silently did nothing — leaving the variable unset, which the server then rejected with + `Unexpected unset value for bind variable N`; + * the object mapper leaves the corresponding property unset instead of failing with + `ArrayIndexOutOfBoundsException`. Watch for this if you use `@Column(caseSensitive = true)` + with a name whose case does not match the column: the mismatch is now silent, in the same way + it already was for an unquoted name that matches nothing. + + An unquoted name that matches no definition already behaved this way; this change makes the + quoted form consistent with it. + ### 3.6.0 1. `ConsistencyLevel.LOCAL_SERIAL.isDCLocal()` now returns true. In driver From d97336b252f5491e4696202e89f04c774f5c9103 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Wed, 26 Aug 2026 15:12:23 +0200 Subject: [PATCH 2/4] docs: discourage binding anonymous markers by name (DRIVER-903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prepared-statement page recommended filling anonymous `?` markers through the name the server synthesizes for them. That name is not part of any contract, and it varies by release line rather than along one version sequence: 2024.1 spells an IN relation's marker in(col), 2026.1.8 spells it IN(col), and SCYLLADB-3454 restores in(col) in 2026.1.12 and 2026.2.6 — which is what broke CUSTOMER-583 on upgrade. Recommend positional binding for `?` and named binding only for explicit `:name`. Simple statements get the same advice, since the coordinator resolves their names. The unset-values paragraph gains the heading it was missing. Co-Authored-By: Claude Opus 5 (1M context) --- manual/statements/prepared/README.md | 48 +++++++++++++++++++++++++--- manual/statements/simple/README.md | 8 +++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/manual/statements/prepared/README.md b/manual/statements/prepared/README.md index ff1dca78057..a83b97af3be 100644 --- a/manual/statements/prepared/README.md +++ b/manual/statements/prepared/README.md @@ -96,18 +96,54 @@ BoundStatement bound = ps2.bind() .setString("d", "LCD screen"); ``` -You can use named setters even if the query uses anonymous parameters; -Cassandra will name the parameters after the column they apply to: +#### Anonymous markers and server-synthesized names + +Named setters also work when the query uses anonymous `?` markers: the +server synthesizes a name for each one, usually after the column it +applies to. ```java +// Works, but relies on a name the server made up: BoundStatement bound = ps1.bind() .setString("sku", "324378") .setString("description", "LCD screen"); ``` -This can be ambiguous if the query uses the same column multiple times, -for example: `select * from sales where sku = ? and date > ? and date < -?`. In these situations, use positional setters or named parameters. +**Avoid relying on this.** Synthesized names are not part of any API +contract, and their spelling differs between server release lines, not +just between successive versions. For +`SELECT ... WHERE application_id IN ?`, ScyllaDB's 2024.1 releases name +the marker `in(application_id)`, while 2026.1.8 names it +`IN(application_id)`; the lowercase spelling is restored in 2026.1.12 and +2026.2.6. Apache Cassandra names it `in(application_id)`. The regression +was reported against a driver that matches these names exactly: the +hardcoded lowercase spelling stopped resolving, the variable went out +unset, and the server rejected the request with `Unexpected unset value +for bind variable 1`. This driver is more forgiving, but only on some of +its lookup paths — see below. The spelling can even vary from node to +node during a rolling upgrade, because the driver keeps whichever +metadata the node that served the `PREPARE` sent back. + +The rule of thumb: **bind `?` markers positionally, and use named setters +only for markers you named yourself with `:name`.** + +If you address a synthesized name anyway, note that the name setters match +case-insensitively, so `setList("in(pk)", ...)` still finds a variable the +server called `IN(pk)` — a change of case alone is survivable. Double +quoting the name, as in `setList("\"in(pk)\"", ...)`, opts into an exact +match instead: it does not resolve, and the setter throws +`IllegalArgumentException` rather than quietly leaving the variable unset. +Note that the exception comes from the setter — querying the metadata +directly, with `ps.getVariables().getIndexOf(...)`, reports the same miss +as `-1`. + +Finally, a named setter writes **every** variable that matches the name, +not just the first one. Names are therefore ambiguous whenever a query +mentions the same column more than once, for example: `select * from sales +where sku = ? and date > ? and date < ?` or `... where a in ? and a in ?`. +Use positional setters in those cases. + +#### Unset values For native protocol V3 or below, all variables must be bound. With native protocol V4 or above, variables can be left unset, in which case they @@ -128,6 +164,8 @@ bound.unset(1); bound.unset("description"); ``` +#### Reading and reusing bound statements + A bound statement also has getters to retrieve the values. Note that this has a small performance overhead since values are stored in their serialized form. diff --git a/manual/statements/simple/README.md b/manual/statements/simple/README.md index b08adc7b018..635b9f51ed7 100644 --- a/manual/statements/simple/README.md +++ b/manual/statements/simple/README.md @@ -64,6 +64,14 @@ Instead of sending a raw query string, you can use bind markers and provide valu ImmutableMap.of("n", paramName)); ``` +Unlike a prepared statement, a simple statement is not parsed by the driver, so named values are +resolved by the **coordinator**, not locally: the driver sends the names along with the query and +does not check them against anything. Name them yourself with `:name` markers, and fill anonymous +`?` markers positionally. Leaning on the names the server synthesizes for anonymous markers (see +[prepared statements](../prepared/#anonymous-markers-and-server-synthesized-names)) is an even worse +idea here than with a prepared statement: nothing on the client side can tell you the name is wrong, +so the query simply fails on the coordinator. + This syntax has a few advantages: * if the values already come from some other part of your code, it looks cleaner than doing the concatenation yourself; From c22afdeeb6d88c8d5262c3481d9682399e770608 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Wed, 26 Aug 2026 15:12:23 +0200 Subject: [PATCH 3/4] test: cover synthesized IN-marker binding end to end (DRIVER-903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreparedStatementTest gains the live counterpart of the ColumnDefinitions unit tests: it prepares a query with an anonymous `IN ?` marker, reads the synthesized name back from the metadata rather than hardcoding a spelling, and asserts that both cases of it resolve to the same variable and return the same rows — alongside the positional binding the manual recommends. A server that names the marker plainly `k` skips the test rather than failing it: the spelling is what this branch refuses to treat as a contract. Verified against Scylla 2024.1.21, which sends `in(k)`, and 2026.1.10, which sends `IN(k)`. Co-Authored-By: Claude Opus 5 (1M context) --- .../driver/core/PreparedStatementTest.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/driver-core/src/test/java/com/datastax/driver/core/PreparedStatementTest.java b/driver-core/src/test/java/com/datastax/driver/core/PreparedStatementTest.java index 626c9aee2a9..2a107432774 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/PreparedStatementTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/PreparedStatementTest.java @@ -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; @@ -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 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 selectedInts(BoundStatement bound) { + List values = new ArrayList(); + for (Row row : session().execute(bound)) { + values.add(row.getInt("i")); + } + return values; + } } From 55939960e2ad24d2e2bdde6a36993ec90c5f0609 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 27 Aug 2026 11:55:44 +0200 Subject: [PATCH 4/4] fix: pin Locale.ROOT when folding identifiers (DRIVER-903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metadata.handleId and its verbatim copy in querybuilder.Utils folded identifiers with the default locale, and reach that branch only for unquoted ASCII ids, where a Turkish locale maps I to the dotless ı. So getTable("ID_TABLE") missed the table reported as id_table, and maybeAddRoutingKey compared ıd against a partition key named id, dropping the statement out of token-aware routing. DataType.Name.toString() folded the same way, and the schema builder splices it into CQL, so an int column rendered as ınt. driver-mapping had it throughout: annotation names, the naming conventions, the parsed ConsistencyLevels, and the relaxed-setter lookup, which searched for setİd. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/datastax/driver/core/DataType.java | 6 +- .../com/datastax/driver/core/Metadata.java | 5 +- .../driver/core/querybuilder/Utils.java | 6 +- .../datastax/driver/core/DataTypeTest.java | 25 +++ .../datastax/driver/core/MetadataTest.java | 18 ++ .../core/querybuilder/QueryBuilderTest.java | 22 +++ .../driver/mapping/AnnotationParser.java | 19 +- .../driver/mapping/DefaultPropertyMapper.java | 11 +- .../driver/mapping/NamingConventions.java | 28 ++- .../driver/mapping/AnnotationParserTest.java | 171 ++++++++++++++++++ .../mapping/DefaultPropertyMapperTest.java | 113 ++++++++++++ .../driver/mapping/NamingConventionsTest.java | 56 ++++++ upgrade_guide/README.md | 32 +++- 13 files changed, 486 insertions(+), 26 deletions(-) create mode 100644 driver-mapping/src/test/java/com/datastax/driver/mapping/AnnotationParserTest.java create mode 100644 driver-mapping/src/test/java/com/datastax/driver/mapping/DefaultPropertyMapperTest.java diff --git a/driver-core/src/main/java/com/datastax/driver/core/DataType.java b/driver-core/src/main/java/com/datastax/driver/core/DataType.java index 352716e2cdc..931a077212b 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/DataType.java +++ b/driver-core/src/main/java/com/datastax/driver/core/DataType.java @@ -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; @@ -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); } } diff --git a/driver-core/src/main/java/com/datastax/driver/core/Metadata.java b/driver-core/src/main/java/com/datastax/driver/core/Metadata.java index f03e617b206..0af0b817540 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/Metadata.java +++ b/driver-core/src/main/java/com/datastax/driver/core/Metadata.java @@ -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; @@ -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); } // Check if it's enclosed in quotes. If it is, remove them and unescape internal double quotes diff --git a/driver-core/src/main/java/com/datastax/driver/core/querybuilder/Utils.java b/driver-core/src/main/java/com/datastax/driver/core/querybuilder/Utils.java index b1122f437cf..6fe38e19d4a 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/querybuilder/Utils.java +++ b/driver-core/src/main/java/com/datastax/driver/core/querybuilder/Utils.java @@ -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; @@ -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) == '"') diff --git a/driver-core/src/test/java/com/datastax/driver/core/DataTypeTest.java b/driver-core/src/test/java/com/datastax/driver/core/DataTypeTest.java index 7c08f7e26f7..2eed5a25a0f 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/DataTypeTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/DataTypeTest.java @@ -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; @@ -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"); + assertThat(DataType.map(DataType.text(), DataType.timestamp()).toString()) + .isEqualTo("map"); + } finally { + Locale.setDefault(def); + } + } } diff --git a/driver-core/src/test/java/com/datastax/driver/core/MetadataTest.java b/driver-core/src/test/java/com/datastax/driver/core/MetadataTest.java index f3180ab1a33..7c43e156796 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/MetadataTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/MetadataTest.java @@ -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; @@ -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"); diff --git a/driver-core/src/test/java/com/datastax/driver/core/querybuilder/QueryBuilderTest.java b/driver-core/src/test/java/com/datastax/driver/core/querybuilder/QueryBuilderTest.java index 986e37964aa..055fa9dbcdc 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/querybuilder/QueryBuilderTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/querybuilder/QueryBuilderTest.java @@ -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; @@ -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() { diff --git a/driver-mapping/src/main/java/com/datastax/driver/mapping/AnnotationParser.java b/driver-mapping/src/main/java/com/datastax/driver/mapping/AnnotationParser.java index 059ac5b93b2..85742e32ae3 100644 --- a/driver-mapping/src/main/java/com/datastax/driver/mapping/AnnotationParser.java +++ b/driver-mapping/src/main/java/com/datastax/driver/mapping/AnnotationParser.java @@ -40,6 +40,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -78,17 +79,23 @@ static EntityMapper parseEntity( entityClass)); } + // ROOT, not the default locale: this name is handed to the schema metadata lookup below, so + // @Table(name = "ID_TABLE") would fold to a dotless id_table in a Turkish JVM and never match. String tableName = - table.caseSensitiveTable() ? Metadata.quote(table.name()) : table.name().toLowerCase(); + table.caseSensitiveTable() + ? Metadata.quote(table.name()) + : table.name().toLowerCase(Locale.ROOT); + // ROOT, not the default locale: a Turkish JVM upper-cases "serial" to SERIAL with a dotted I, + // which is not a ConsistencyLevel constant, so valueOf would throw. ConsistencyLevel writeConsistency = table.writeConsistency().isEmpty() ? null - : ConsistencyLevel.valueOf(table.writeConsistency().toUpperCase()); + : ConsistencyLevel.valueOf(table.writeConsistency().toUpperCase(Locale.ROOT)); ConsistencyLevel readConsistency = table.readConsistency().isEmpty() ? null - : ConsistencyLevel.valueOf(table.readConsistency().toUpperCase()); + : ConsistencyLevel.valueOf(table.readConsistency().toUpperCase(Locale.ROOT)); KeyspaceMetadata keyspaceMetadata = mappingManager.getSession().getCluster().getMetadata().getKeyspace(keyspaceName); @@ -178,8 +185,10 @@ static MappedUDTCodec parseUDT( udtClass)); } + // ROOT, not the default locale: keyspaceMetadata.getUserType below matches on this name, so + // @UDT(name = "ID_TYPE") would fold to a dotless id_type in a Turkish JVM and never resolve. String udtName = - udt.caseSensitiveType() ? Metadata.quote(udt.name()) : udt.name().toLowerCase(); + udt.caseSensitiveType() ? Metadata.quote(udt.name()) : udt.name().toLowerCase(Locale.ROOT); KeyspaceMetadata keyspaceMetadata = mappingManager.getSession().getCluster().getMetadata().getKeyspace(keyspaceName); @@ -277,7 +286,7 @@ else if (allParamsNamed != thisParamNamed) cl = options.consistency().isEmpty() ? null - : ConsistencyLevel.valueOf(options.consistency().toUpperCase()); + : ConsistencyLevel.valueOf(options.consistency().toUpperCase(Locale.ROOT)); fetchSize = options.fetchSize(); tracing = options.tracing(); if (options.idempotent().length > 1) { diff --git a/driver-mapping/src/main/java/com/datastax/driver/mapping/DefaultPropertyMapper.java b/driver-mapping/src/main/java/com/datastax/driver/mapping/DefaultPropertyMapper.java index c77dcc13b6b..049eb13f18d 100644 --- a/driver-mapping/src/main/java/com/datastax/driver/mapping/DefaultPropertyMapper.java +++ b/driver-mapping/src/main/java/com/datastax/driver/mapping/DefaultPropertyMapper.java @@ -46,6 +46,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.slf4j.Logger; @@ -391,8 +392,11 @@ protected Method locateSetter(Class mappedClass, PropertyDescriptor property) if (setter == null) { // JAVA-984: look for a "relaxed" setter, ie. a setter whose return type may be anything String propertyName = property.getName(); + // ROOT, not the default locale: this builds a Java method name, and in a Turkish JVM a + // property named id would yield setId spelled with a dotted I. getMethod would then miss and + // the NoSuchMethodException below is swallowed, so the setter would go silently undetected. String setterName = - "set" + propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1); + "set" + propertyName.substring(0, 1).toUpperCase(Locale.ROOT) + propertyName.substring(1); try { Method m = mappedClass.getMethod(setterName, property.getPropertyType()); if (!Modifier.isStatic(m.getModifiers())) setter = m; @@ -447,7 +451,10 @@ protected String inferMappedName( if (!udtMappedField.name().isEmpty()) mappedName = udtMappedField.name(); } if (mappedName != null) { - return caseSensitive ? Metadata.quote(mappedName) : mappedName.toLowerCase(); + // ROOT, not the default locale: this is the column name the mapper reads and writes, so + // @Column(name = "ID") would fold to a dotless id in a Turkish JVM and the property would + // silently fail to bind against a column the server calls id. + return caseSensitive ? Metadata.quote(mappedName) : mappedName.toLowerCase(Locale.ROOT); } // Otherwise delegate to the naming strategy diff --git a/driver-mapping/src/main/java/com/datastax/driver/mapping/NamingConventions.java b/driver-mapping/src/main/java/com/datastax/driver/mapping/NamingConventions.java index ef3891b67d8..d43811633d3 100644 --- a/driver-mapping/src/main/java/com/datastax/driver/mapping/NamingConventions.java +++ b/driver-mapping/src/main/java/com/datastax/driver/mapping/NamingConventions.java @@ -19,9 +19,17 @@ import java.util.Comparator; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.regex.Pattern; -/** Implementations of industry common naming conventions. */ +/** + * Implementations of industry common naming conventions. + * + *

Every case fold below pins {@link Locale#ROOT} rather than using the JVM default locale. The + * result becomes a CQL identifier (see {@code DefaultPropertyMapper#getMappedName}), so under a + * Turkish default locale a property named {@code id} would map to a column spelled with a dotless + * {@code ı} or a dotted {@code İ}, and every lookup for {@code id} would miss. + */ public class NamingConventions { /** @@ -142,13 +150,13 @@ public String join(List input) { Word word = input.get(i); String value; if (i == 0) { - value = word.getValue().toLowerCase(); + value = word.getValue().toLowerCase(Locale.ROOT); } else if (upperCaseAbbreviations && word.isAbbreviation()) { - value = word.getValue().toUpperCase(); + value = word.getValue().toUpperCase(Locale.ROOT); } else { value = - word.getValue().substring(0, 1).toUpperCase() - + word.getValue().substring(1).toLowerCase(); + word.getValue().substring(0, 1).toUpperCase(Locale.ROOT) + + word.getValue().substring(1).toLowerCase(Locale.ROOT); } builder.append(value); } @@ -213,11 +221,11 @@ public String join(List input) { for (Word word : input) { String value; if (upperCaseAbbreviations && word.isAbbreviation()) { - value = word.getValue().toUpperCase(); + value = word.getValue().toUpperCase(Locale.ROOT); } else { value = - word.getValue().substring(0, 1).toUpperCase() - + word.getValue().substring(1).toLowerCase(); + word.getValue().substring(0, 1).toUpperCase(Locale.ROOT) + + word.getValue().substring(1).toLowerCase(Locale.ROOT); } builder.append(value); } @@ -303,7 +311,7 @@ public String join(List input) { builder.append(input.get(i).getValue()); } String result = builder.toString(); - return isUpperCase ? result.toUpperCase() : result.toLowerCase(); + return isUpperCase ? result.toUpperCase(Locale.ROOT) : result.toLowerCase(Locale.ROOT); } } @@ -329,7 +337,7 @@ public String join(List input) { builder.append(word.getValue()); } String result = builder.toString(); - return isUpperCase ? result.toUpperCase() : result.toLowerCase(); + return isUpperCase ? result.toUpperCase(Locale.ROOT) : result.toLowerCase(Locale.ROOT); } } } diff --git a/driver-mapping/src/test/java/com/datastax/driver/mapping/AnnotationParserTest.java b/driver-mapping/src/test/java/com/datastax/driver/mapping/AnnotationParserTest.java new file mode 100644 index 00000000000..ae6c13b112c --- /dev/null +++ b/driver-mapping/src/test/java/com/datastax/driver/mapping/AnnotationParserTest.java @@ -0,0 +1,171 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.datastax.driver.mapping; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.KeyspaceMetadata; +import com.datastax.driver.core.Metadata; +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Session; +import com.datastax.driver.mapping.annotations.Accessor; +import com.datastax.driver.mapping.annotations.Query; +import com.datastax.driver.mapping.annotations.QueryParameters; +import com.datastax.driver.mapping.annotations.Table; +import com.datastax.driver.mapping.annotations.UDT; +import java.util.Locale; +import org.testng.annotations.Test; + +/** + * {@link AnnotationParser} folds case in five places: the {@code @Table} and {@code @UDT} names it + * hands to the schema metadata, and the three consistency levels it parses out of annotation + * strings. None of them may depend on the JVM's default locale. In a Turkish locale an upper-case I + * folds to a dotless {@code ı} and a lower-case i to a dotted {@code İ}, so {@code @Table(name = + * "ID_TABLE")} would look up a table no server ever reported, and {@code writeConsistency = + * "serial"} would fail {@code ConsistencyLevel.valueOf}. + * + *

Every one of those folds happens before the parser needs anything a live cluster provides, so + * the tests drive it with a mocked manager and read the folded name back out of the failure the + * metadata lookup raises. That keeps them in the unit group: the CCM mapper tests that do reach + * this class never change the default locale, which is what left these five call sites uncovered. + */ +public class AnnotationParserTest { + + private static final Locale TURKISH = new Locale("tr", "TR"); + private static final String KEYSPACE = "ks"; + + @Table(name = "ID_TABLE") + static class IdTable {} + + @Table(name = "t", writeConsistency = "serial", readConsistency = "serial") + static class SerialConsistencyTable {} + + @UDT(name = "ID_TYPE") + static class IdType {} + + @Accessor + interface SerialConsistencyAccessor { + @Query("SELECT * FROM ks.t") + @QueryParameters(consistency = "serial") + ResultSet all(); + } + + /** + * The {@code @Table} name is folded and then handed straight to {@code getTable}, so an unpinned + * fold makes the mapper query a dotless table name that cannot match the schema. + */ + @Test(groups = "unit") + public void should_fold_table_name_in_any_default_locale() { + MappingManager manager = managerReporting(mock(KeyspaceMetadata.class)); + + Locale def = Locale.getDefault(); + try { + Locale.setDefault(TURKISH); + assertThat(parseEntityFailure(IdTable.class, manager)) + .contains("Table or materialized view id_table does not exist"); + } finally { + Locale.setDefault(def); + } + } + + /** + * Both {@code @Table} consistency levels are upper-cased before {@code valueOf}. Unpinned, a + * Turkish locale turns "serial" into SERİAL, which is not a constant — reaching the table lookup + * at all is what proves the two parses survived. + */ + @Test(groups = "unit") + public void should_parse_table_consistency_levels_in_any_default_locale() { + MappingManager manager = managerReporting(mock(KeyspaceMetadata.class)); + + Locale def = Locale.getDefault(); + try { + Locale.setDefault(TURKISH); + assertThat(parseEntityFailure(SerialConsistencyTable.class, manager)) + .contains("Table or materialized view t does not exist"); + } finally { + Locale.setDefault(def); + } + } + + /** The {@code @UDT} name reaches {@code getUserType} the same way the table name does. */ + @Test(groups = "unit") + public void should_fold_udt_name_in_any_default_locale() { + MappingManager manager = managerReporting(mock(KeyspaceMetadata.class)); + + Locale def = Locale.getDefault(); + try { + Locale.setDefault(TURKISH); + assertThat(parseUdtFailure(IdType.class, manager)) + .contains("User type id_type does not exist"); + } finally { + Locale.setDefault(def); + } + } + + /** + * The accessor's {@code @QueryParameters} consistency is a third, independent {@code valueOf} + * call site, and it is reached without touching the session at all. + */ + @Test(groups = "unit") + public void should_parse_accessor_consistency_in_any_default_locale() { + MappingManager manager = mock(MappingManager.class); + + Locale def = Locale.getDefault(); + try { + Locale.setDefault(TURKISH); + assertThat(AnnotationParser.parseAccessor(SerialConsistencyAccessor.class, manager)) + .isNotNull(); + } finally { + Locale.setDefault(def); + } + } + + /** A manager whose cluster reports the given keyspace, and no tables or user types in it. */ + private static MappingManager managerReporting(KeyspaceMetadata keyspace) { + Metadata metadata = mock(Metadata.class); + when(metadata.getKeyspace(KEYSPACE)).thenReturn(keyspace); + Cluster cluster = mock(Cluster.class); + when(cluster.getMetadata()).thenReturn(metadata); + Session session = mock(Session.class); + when(session.getCluster()).thenReturn(cluster); + MappingManager manager = mock(MappingManager.class); + when(manager.getSession()).thenReturn(session); + return manager; + } + + /** Returns the message of the failure the missing table raises, which carries the folded name. */ + private static String parseEntityFailure(Class entityClass, MappingManager manager) { + try { + AnnotationParser.parseEntity(entityClass, KEYSPACE, manager); + throw new AssertionError("expected the parse to fail on the missing table"); + } catch (IllegalArgumentException e) { + return e.getMessage(); + } + } + + /** As above, for the missing user type. */ + private static String parseUdtFailure(Class udtClass, MappingManager manager) { + try { + AnnotationParser.parseUDT(udtClass, KEYSPACE, manager); + throw new AssertionError("expected the parse to fail on the missing user type"); + } catch (IllegalArgumentException e) { + return e.getMessage(); + } + } +} diff --git a/driver-mapping/src/test/java/com/datastax/driver/mapping/DefaultPropertyMapperTest.java b/driver-mapping/src/test/java/com/datastax/driver/mapping/DefaultPropertyMapperTest.java new file mode 100644 index 00000000000..21e0b0bfc50 --- /dev/null +++ b/driver-mapping/src/test/java/com/datastax/driver/mapping/DefaultPropertyMapperTest.java @@ -0,0 +1,113 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.datastax.driver.mapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.driver.mapping.annotations.Column; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import org.testng.annotations.Test; + +/** + * The mapper derives both CQL column names and Java accessor names by folding case, so neither may + * depend on the JVM's default locale. In a Turkish locale an upper-case I folds to a dotless {@code + * ı} and a lower-case i to a dotted {@code İ}, which is exactly the letter that broke CUSTOMER-583 + * one layer up in {@code ColumnDefinitions}. + */ +public class DefaultPropertyMapperTest { + + private static final Locale TURKISH = new Locale("tr", "TR"); + + static class Entity { + @Column(name = "ID") + int id; + } + + /** A property whose setter is "relaxed" (it returns the entity rather than void). */ + static class RelaxedSetterEntity { + private int id; + + public int getId() { + return id; + } + + public RelaxedSetterEntity setId(int id) { + this.id = id; + return this; + } + } + + /** + * An explicit, non-case-sensitive {@code @Column(name = "ID")} is folded to the name the server + * holds. Under a Turkish default locale an unpinned fold yields a dotless id, and every read and + * write of the property would then miss the column. + */ + @Test(groups = "unit") + public void should_fold_explicit_column_name_in_any_default_locale() throws Exception { + Field field = Entity.class.getDeclaredField("id"); + Map, Annotation> annotations = + Collections., Annotation>singletonMap( + Column.class, field.getAnnotation(Column.class)); + + Locale def = Locale.getDefault(); + try { + Locale.setDefault(TURKISH); + String mappedName = + new DefaultPropertyMapper().inferMappedName(Entity.class, "id", annotations); + assertThat(mappedName).isEqualTo("id"); + } finally { + Locale.setDefault(def); + } + } + + /** + * The relaxed-setter lookup builds a Java method name from the property name. Under a Turkish + * default locale an unpinned fold looks for setId spelled with a dotted I; the resulting + * NoSuchMethodException is swallowed, so the setter would go silently undetected rather than + * failing loudly. + */ + @Test(groups = "unit") + public void should_locate_relaxed_setter_in_any_default_locale() throws Exception { + PropertyDescriptor descriptor = null; + for (PropertyDescriptor candidate : + Introspector.getBeanInfo(RelaxedSetterEntity.class).getPropertyDescriptors()) { + if ("id".equals(candidate.getName())) { + descriptor = candidate; + } + } + assertThat(descriptor).isNotNull(); + // Precondition for the branch under test: a relaxed setter is not a bean write method. + assertThat(descriptor.getWriteMethod()).isNull(); + + Locale def = Locale.getDefault(); + try { + Locale.setDefault(TURKISH); + Method setter = + new DefaultPropertyMapper().locateSetter(RelaxedSetterEntity.class, descriptor); + assertThat(setter).isNotNull(); + assertThat(setter.getName()).isEqualTo("setId"); + } finally { + Locale.setDefault(def); + } + } +} diff --git a/driver-mapping/src/test/java/com/datastax/driver/mapping/NamingConventionsTest.java b/driver-mapping/src/test/java/com/datastax/driver/mapping/NamingConventionsTest.java index 8cef2eeb7af..d418293e477 100644 --- a/driver-mapping/src/test/java/com/datastax/driver/mapping/NamingConventionsTest.java +++ b/driver-mapping/src/test/java/com/datastax/driver/mapping/NamingConventionsTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.Locale; import org.testng.annotations.Test; /** Test for JAVA-1316 - test combinations of different {@link NamingConventions} implementation. */ @@ -483,6 +484,61 @@ public void upper_camel_case_with_abbr_to_lower_camel_case_with_abbr() { "myXMLParser"); } + /** + * The conventions fold case to produce a CQL identifier, so the JVM's default locale must not + * decide the result. In a Turkish locale a lower-case i upper-cases to a dotted I and an + * upper-case I lower-cases to a dotless one, so every name below would come out unmatchable + * against the column the server actually holds. Each input deliberately carries an i. + * + *

The abbreviation branches of the two camel-case joins are pinned as well, but no input + * discriminates them, which was checked by reverting each one in turn: the splitter only marks an + * all-upper-case word as an abbreviation, so the fold applied there is a no-op in any locale. + */ + @Test(groups = "unit") + public void should_apply_conventions_in_any_default_locale() { + Locale def = Locale.getDefault(); + try { + Locale.setDefault(new Locale("tr", "TR")); + test(NamingConventions.LOWER_CAMEL_CASE, NamingConventions.UPPER_CASE, "id", "ID"); + test(NamingConventions.LOWER_CAMEL_CASE, NamingConventions.LOWER_CASE, "ID", "id"); + test( + NamingConventions.LOWER_CAMEL_CASE, + NamingConventions.UPPER_SNAKE_CASE, + "minPrice", + "MIN_PRICE"); + test( + NamingConventions.LOWER_CAMEL_CASE, + NamingConventions.UPPER_CAMEL_CASE, + "itemId", + "ItemId"); + // The three below drive the lower-camel join, one per fold it applies: the first word, the + // leading letter of a later word, and that word's tail. + test( + NamingConventions.UPPER_SNAKE_CASE, + NamingConventions.LOWER_CAMEL_CASE, + "ITEM_ID", + "itemId"); + test( + NamingConventions.LOWER_SNAKE_CASE, + NamingConventions.LOWER_CAMEL_CASE, + "item_id", + "itemId"); + test( + NamingConventions.UPPER_SNAKE_CASE, + NamingConventions.LOWER_CAMEL_CASE, + "ITEM_XID", + "itemXid"); + // Drives the upper-camel join's tail fold, which the itemId case above leaves untouched. + test( + NamingConventions.UPPER_SNAKE_CASE, + NamingConventions.UPPER_CAMEL_CASE, + "ITEM_XID", + "ItemXid"); + } finally { + Locale.setDefault(def); + } + } + private void test( NamingConvention inputConvention, NamingConvention outputConvention, diff --git a/upgrade_guide/README.md b/upgrade_guide/README.md index 2c66c23dcb9..812e9428ca4 100644 --- a/upgrade_guide/README.md +++ b/upgrade_guide/README.md @@ -12,18 +12,38 @@ versions of the Java driver. * `contains()` returns `false` where it returned `true`; * `getIndexOf()` returns `-1` where it threw `ArrayIndexOutOfBoundsException`; - * the name-based getters and setters on `Row` and `BoundStatement` throw - `IllegalArgumentException`, where a getter threw `ArrayIndexOutOfBoundsException` and a setter - silently did nothing — leaving the variable unset, which the server then rejected with - `Unexpected unset value for bind variable N`; + * the name-based getters on `Row`, and the getters and setters on `BoundStatement`, throw + `IllegalArgumentException`. A getter previously threw `ArrayIndexOutOfBoundsException`; a + setter silently did nothing — leaving the variable unset, which the server then rejected + with `Unexpected unset value for bind variable N`; * the object mapper leaves the corresponding property unset instead of failing with `ArrayIndexOutOfBoundsException`. Watch for this if you use `@Column(caseSensitive = true)` - with a name whose case does not match the column: the mismatch is now silent, in the same way - it already was for an unquoted name that matches nothing. + with a name whose case does not match the column: the mismatch is now silent, in the + same way it already was for an unquoted name that matches nothing. An unquoted name that matches no definition already behaved this way; this change makes the quoted form consistent with it. +2. Identifier case folding now pins `Locale.ROOT`, so it no longer depends on the JVM's default + locale. This affects bind-variable names, schema metadata lookups such as + `KeyspaceMetadata.getTable()`, the column names the query builder matches against a table's + partition key, and the type names the schema builder renders. Previously an unquoted ASCII + identifier containing an uppercase `I` was folded with the default locale, so on a + Turkish-locale JVM it became the dotless `ı`: `getTable("ID_TABLE")` looked up `ıd_table` and + found nothing; a query-builder clause on `ID` no longer matched a partition key named `id`, + silently costing the statement its routing key and with it token-aware routing; and + `DataType.cint()` rendered itself as `ınt`, so a `CREATE TABLE`, `CREATE TYPE`, + `ALTER ... TYPE` or `ADD` clause naming a type that contains an `I` produced CQL the server + rejected outright. + + The object mapper is affected in the same way: an explicit `@Table`, `@UDT`, `@Column` or + `@Field` name, and every name the built-in `NamingConventions` derive, are now folded with + `Locale.ROOT`. A custom `NamingStrategy` is unaffected: the mapper never folded its result, it + only passes it to `Metadata.quoteIfNecessary()`, so choosing a locale remains the strategy's own + business. Two further consequences were mapper-specific — `@Table(writeConsistency = "serial")` + failed to parse as a `ConsistencyLevel`, and the relaxed-setter lookup searched for a method + name spelled with a dotted `I` and silently found none, leaving the property without a setter. + ### 3.6.0 1. `ConsistencyLevel.LOCAL_SERIAL.isDCLocal()` now returns true. In driver