Skip to content
Merged
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
@@ -1,6 +1,7 @@
package com.datastax.oss.driver.api.core;

import edu.umd.cs.findbugs.annotations.NonNull;
import java.util.Locale;

public enum CQL4SkipMetadataResolveMethod {
// SMART (Default) - Disables the skip metadata flag only for wildcard selects (`SELECT * FROM`)
Expand Down Expand Up @@ -28,7 +29,9 @@ public String toString() {
@NonNull
public static CQL4SkipMetadataResolveMethod fromValue(@NonNull String value)
throws IllegalArgumentException {
switch (value.toLowerCase()) {
// ROOT, not the default locale: in a Turkish JVM the I of "DISABLED" folds to a dotless
// small letter, which matches no case below and rejects a valid configuration.
switch (value.toLowerCase(Locale.ROOT)) {
case "smart":
return SMART;
case "enabled":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
Expand Down Expand Up @@ -177,7 +178,9 @@ private RequestRoutingMethod parseLwtRequestRoutingMethod() {
return RequestRoutingMethod.PRESERVE_REPLICA_ORDER;
}
try {
return RequestRoutingMethod.valueOf(methodString.toUpperCase());
// ROOT, not the default locale: in a Turkish JVM the i of "preserve_replica_order" folds
// to a dotted capital, so a valid setting would be warned about and silently dropped.
return RequestRoutingMethod.valueOf(methodString.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
LOG.warn(
"[{}] Unknown request routing method '{}', defaulting to PRESERVE_REPLICA_ORDER",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.oss.driver.api.core;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.datastax.oss.driver.TestDataProviders;
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
import com.tngtech.java.junit.dataprovider.UseDataProvider;
import java.util.Locale;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(DataProviderRunner.class)
public class CQL4SkipMetadataResolveMethodTest {

/**
* {@code fromValue} folds the configured value to compare it, so it must pin {@link Locale#ROOT}:
* the I of {@code DISABLED} and {@code ENABLED} folds to a dotless small letter in a Turkish JVM,
* which would match none of the cases and reject a valid configuration at session build.
*/
@Test
@UseDataProvider(location = TestDataProviders.class, value = "locales")
public void should_parse_value_in_any_default_locale(Locale locale) {
Locale def = Locale.getDefault();
try {
Locale.setDefault(locale);
assertThat(CQL4SkipMetadataResolveMethod.fromValue("DISABLED"))
.isEqualTo(CQL4SkipMetadataResolveMethod.DISABLED);
assertThat(CQL4SkipMetadataResolveMethod.fromValue("ENABLED"))
.isEqualTo(CQL4SkipMetadataResolveMethod.ENABLED);
assertThat(CQL4SkipMetadataResolveMethod.fromValue("SMART"))
.isEqualTo(CQL4SkipMetadataResolveMethod.SMART);
assertThat(CQL4SkipMetadataResolveMethod.fromValue("disabled"))
.isEqualTo(CQL4SkipMetadataResolveMethod.DISABLED);
} finally {
Locale.setDefault(def);
}
}

@Test
public void should_fail_to_parse_unknown_value() {
assertThatThrownBy(() -> CQL4SkipMetadataResolveMethod.fromValue("nope"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unsupported value nope");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ public void should_fail_to_build_from_valid_cql_if_reserved_keyword() {
CqlIdentifier.fromCql("Create");
}

/**
* Servers synthesize names like {@code IN(ck)} for anonymous markers of an IN relation. They can
* only be turned into an identifier through {@link CqlIdentifier#fromInternal} or a double-quoted
* CQL form, which is one of the reasons applications should bind such markers positionally
* instead. See the prepared-statements manual page.
*/
@Test(expected = IllegalArgumentException.class)
public void should_fail_to_build_from_valid_cql_if_synthesized_marker_name() {
CqlIdentifier.fromCql("IN(ck)");
}

@Test
public void should_format_as_cql() {
assertThat(CqlIdentifier.fromInternal("foo").asCql(false)).isEqualTo("\"foo\"");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,29 @@
package com.datastax.oss.driver.internal.core.cql;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.datastax.oss.driver.api.core.CqlIdentifier;
import com.datastax.oss.driver.api.core.DefaultProtocolVersion;
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
import com.datastax.oss.driver.api.core.config.DriverExecutionProfile;
import com.datastax.oss.driver.api.core.cql.BoundStatement;
import com.datastax.oss.driver.api.core.cql.ColumnDefinition;
import com.datastax.oss.driver.api.core.cql.ColumnDefinitions;
import com.datastax.oss.driver.api.core.cql.PreparedStatement;
import com.datastax.oss.driver.api.core.cql.Statement;
import com.datastax.oss.driver.api.core.metadata.schema.ColumnMetadata;
import com.datastax.oss.driver.api.core.time.TimestampGenerator;
import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry;
import com.datastax.oss.driver.internal.core.DefaultConsistencyLevelRegistry;
import com.datastax.oss.driver.internal.core.ProtocolVersionRegistry;
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
import com.datastax.oss.protocol.internal.Message;
import com.datastax.oss.protocol.internal.request.Execute;
import java.nio.ByteBuffer;
import java.util.List;
import org.junit.Test;

Expand Down Expand Up @@ -87,4 +102,60 @@ private ColumnDefinitions variables(String... columnNames) {
}
return DefaultColumnDefinitions.valueOf(columns.build());
}

/**
* The invariant that makes the driver immune to CUSTOMER-583: an EXECUTE carries its values
* positionally, so the name the server synthesized for an anonymous marker never travels back to
* the coordinator and cannot be re-resolved there. Everything else in this area only guards the
* local name-to-index lookup; if this branch ever started sending named values, that lookup would
* be bypassed and a server that respelled a marker between PREPARE and EXECUTE would break
* applications again.
*/
@Test
public void should_send_bound_statement_values_positionally() {
List<ByteBuffer> values =
ImmutableList.of(ByteBuffer.allocate(1), ByteBuffer.allocate(2), ByteBuffer.allocate(3));

Message message = Conversions.toMessage(boundStatement(values), profile(), context());

assertThat(message).isInstanceOf(Execute.class);
Execute execute = (Execute) message;
assertThat(execute.options.namedValues).isEmpty();
assertThat(execute.options.positionalValues).isEqualTo(values);
}

private BoundStatement boundStatement(List<ByteBuffer> values) {
// Built before the stubbing below: variables() mocks in turn, and nesting that inside a when()
// argument leaves Mockito with an unfinished stubbing.
ColumnDefinitions resultSetDefinitions = variables("v");
PreparedStatement preparedStatement = mock(PreparedStatement.class);
when(preparedStatement.getId()).thenReturn(ByteBuffer.allocate(4));
when(preparedStatement.getResultSetDefinitions()).thenReturn(resultSetDefinitions);
BoundStatement boundStatement = mock(BoundStatement.class);
when(boundStatement.getPreparedStatement()).thenReturn(preparedStatement);
when(boundStatement.getValues()).thenReturn(values);
when(boundStatement.getQueryTimestamp()).thenReturn(Statement.NO_DEFAULT_TIMESTAMP);
when(boundStatement.getNowInSeconds()).thenReturn(Statement.NO_NOW_IN_SECONDS);
return boundStatement;
}

private DriverExecutionProfile profile() {
DriverExecutionProfile profile = mock(DriverExecutionProfile.class);
when(profile.getString(DefaultDriverOption.REQUEST_CONSISTENCY)).thenReturn("LOCAL_ONE");
when(profile.getInt(DefaultDriverOption.REQUEST_PAGE_SIZE)).thenReturn(5000);
when(profile.getString(DefaultDriverOption.REQUEST_SERIAL_CONSISTENCY)).thenReturn("SERIAL");
return profile;
}

private InternalDriverContext context() {
ProtocolVersionRegistry protocolVersionRegistry = mock(ProtocolVersionRegistry.class);
when(protocolVersionRegistry.supports(any(), any())).thenReturn(true);
InternalDriverContext context = mock(InternalDriverContext.class);
when(context.getConsistencyLevelRegistry()).thenReturn(new DefaultConsistencyLevelRegistry());
when(context.getTimestampGenerator()).thenReturn(mock(TimestampGenerator.class));
when(context.getCodecRegistry()).thenReturn(CodecRegistry.DEFAULT);
when(context.getProtocolVersion()).thenReturn(DefaultProtocolVersion.V4);
when(context.getProtocolVersionRegistry()).thenReturn(protocolVersionRegistry);
return context;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ public class IdentifierIndexTest {
private IdentifierIndex index =
new IdentifierIndex(ImmutableList.of(Foo, foo, fOO, Foo, foo, fOO));

// The variable definitions a server returns for
// "SELECT * FROM t WHERE pk = ? AND ck IN ? AND ck IN ?": the markers of the IN relations get 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(ck), 2026.1.8 emits IN(ck), 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. See BoundStatementCcmIT for the end-to-end counterpart of the tests below.
private static final CqlIdentifier pk = CqlIdentifier.fromInternal("pk");
private static final CqlIdentifier upperCaseInCk = CqlIdentifier.fromInternal("IN(ck)");

/**
* Built on demand rather than kept in a field: the constructor folds the names it indexes, so the
* locale test below has to build the index inside its own locale override to cover that half of
* the fold. A field initializer would run before the override and leave the indexing side
* untested.
*/
private static IdentifierIndex synthesizedIndex() {
return new IdentifierIndex(ImmutableList.of(pk, upperCaseInCk, upperCaseInCk));
}

@Test
public void should_find_first_index_of_existing_identifier() {
assertThat(index.firstIndexOf(Foo)).isEqualTo(0);
Expand Down Expand Up @@ -139,4 +159,50 @@ public void should_find_all_indices_of_case_sensitive_name() {
public void should_not_find_indices_of_nonexistent_case_sensitive_name() {
assertThat(index.allIndicesOf("\"FOO\"")).isEmpty();
}

/**
* The regression guard for CUSTOMER-583: the driver resolves bind-variable names locally, and
* that lookup must not care how the server spelled a synthesized name. The locales provider
* matters here rather than incidentally: the letter that flipped is {@code I}, and lowercasing it
* in the Turkish locale yields a dotless {@code ı}, so a lookup that did not pin {@link
* Locale#ROOT} would fail for Turkish users only.
*/
@Test
@UseDataProvider(location = TestDataProviders.class, value = "locales")
public void should_find_synthesized_marker_name_whatever_the_server_spelling(Locale locale) {
Locale def = Locale.getDefault();
try {
Locale.setDefault(locale);
// Built inside the override deliberately, so that the fold the constructor applies to the
// names it indexes is exercised under this locale too, not just the fold on the lookup side.
IdentifierIndex synthesized = synthesizedIndex();
assertThat(synthesized.firstIndexOf("IN(ck)")).isEqualTo(1);
assertThat(synthesized.firstIndexOf("in(ck)")).isEqualTo(1);
assertThat(synthesized.firstIndexOf("In(Ck)")).isEqualTo(1);
} finally {
Locale.setDefault(def);
}
}

/** A named setter writes every matching variable, so repeating a column makes names ambiguous. */
@Test
public void should_find_all_indices_of_synthesized_marker_name_when_column_is_repeated() {
assertThat(synthesizedIndex().allIndicesOf("in(ck)")).containsExactly(1, 2);
}

/** Double-quoting opts into exact matching, which the synthesized spelling can then break. */
@Test
public void should_match_double_quoted_synthesized_marker_name_exactly() {
IdentifierIndex synthesized = synthesizedIndex();
assertThat(synthesized.firstIndexOf("\"IN(ck)\"")).isEqualTo(1);
assertThat(synthesized.firstIndexOf("\"in(ck)\"")).isEqualTo(-1);
}

/** Same for the identifier-based lookup, which is always exact. */
@Test
public void should_match_synthesized_marker_identifier_exactly() {
IdentifierIndex synthesized = synthesizedIndex();
assertThat(synthesized.firstIndexOf(CqlIdentifier.fromInternal("IN(ck)"))).isEqualTo(1);
assertThat(synthesized.firstIndexOf(CqlIdentifier.fromInternal("in(ck)"))).isEqualTo(-1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import ch.qos.logback.classic.spi.ILoggingEvent;
import com.datastax.oss.driver.TestDataProviders;
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
import com.datastax.oss.driver.api.core.config.DriverExecutionProfile;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableSet;
import com.tngtech.java.junit.dataprovider.DataProvider;
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
import com.tngtech.java.junit.dataprovider.UseDataProvider;
import java.util.Locale;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
Expand Down Expand Up @@ -104,6 +107,36 @@ public void should_accept_configuration_combinations(
.getBoolean(DefaultDriverOption.LOAD_BALANCING_POLICY_SLOW_AVOIDANCE, true);
}

/**
* The configured value is upper-cased before {@code valueOf}, so the fold must pin {@link
* Locale#ROOT}: in a Turkish JVM the i of {@code preserve_replica_order} becomes a dotted
* capital, {@code valueOf} then fails, and the policy silently falls back to the value the user
* had asked for anyway — observable only as this warning. The fallback is why the assertion has
* to be on the log rather than on the resolved method.
*/
@Test
@UseDataProvider(location = TestDataProviders.class, value = "locales")
public void should_accept_valid_routing_method_in_any_default_locale(Locale locale) {
when(metadataManager.getContactPoints()).thenReturn(ImmutableSet.of(node1));
when(defaultProfile.getString(
DefaultDriverOption.LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD))
.thenReturn("preserve_replica_order");

Locale def = Locale.getDefault();
try {
Locale.setDefault(locale);
assertThat(new DefaultLoadBalancingPolicy(context, DriverExecutionProfile.DEFAULT_NAME))
.isNotNull();
} finally {
Locale.setDefault(def);
}

verify(appender, atLeast(0)).doAppend(loggingEventCaptor.capture());
assertThat(loggingEventCaptor.getAllValues())
.extracting(ILoggingEvent::getFormattedMessage)
.noneMatch(message -> message.contains("Unknown request routing method"));
}

@DataProvider
public static Object[][] configurationCombinations() {
return new Object[][] {
Expand Down
Loading
Loading