getJavaType() {
+ return GenericType.of(Timestamp.class);
+ }
+
+ @Override
+ public DataType getCqlType() {
+ return instantCodec.getCqlType(); // maps to CQL `timestamp`
+ }
+
+ @Override
+ public ByteBuffer encode(Timestamp value, ProtocolVersion protocolVersion) {
+ return value == null ? null : instantCodec.encode(value.toInstant(), protocolVersion);
+ }
+
+ @Override
+ public Timestamp decode(ByteBuffer bytes, ProtocolVersion protocolVersion) {
+ Instant instant = instantCodec.decode(bytes, protocolVersion);
+ return instant == null ? null : Timestamp.from(instant);
+ }
+
+ @Override
+ public String format(Timestamp value) {
+ return value == null ? "NULL" : instantCodec.format(value.toInstant());
+ }
+
+ @Override
+ public Timestamp parse(String value) {
+ Instant instant = instantCodec.parse(value);
+ return instant == null ? null : Timestamp.from(instant);
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
new file mode 100644
index 000000000000..c5b134d343f5
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
@@ -0,0 +1,16 @@
+# 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.
+
+org.apache.nifi.service.cassandra.CassandraCQLExecutionService
\ No newline at end of file
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceBindValueTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceBindValueTest.java
new file mode 100644
index 000000000000..8253896c3bfc
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceBindValueTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Date;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/**
+ * Covers the conversion applied to a query parameter bound to a {@code timestamp} column, which is the one
+ * scalar type with no {@code Flexible*Codec} registered to coerce a value supplied as text.
+ *
+ * The accepted forms are tabulated rather than written as a method each, so the table reads as the
+ * specification of what {@code toInstant} takes.
+ */
+public class CassandraCQLExecutionServiceBindValueTest {
+
+ private static final Instant EXPECTED = Instant.parse("2026-08-07T14:30:00Z");
+
+ static Stream acceptedForms() {
+ return Stream.of(
+ arguments("an Instant, passed through", EXPECTED),
+ arguments("ISO-8601 text", "2026-08-07T14:30:00Z"),
+ arguments("ISO-8601 text with surrounding whitespace", " 2026-08-07T14:30:00Z "),
+ arguments("epoch millis as text", String.valueOf(EXPECTED.toEpochMilli())),
+ arguments("epoch millis as a Number", EXPECTED.toEpochMilli()),
+ arguments("a java.util.Date", new Date(EXPECTED.toEpochMilli())),
+ arguments("a java.sql.Timestamp", Timestamp.from(EXPECTED)));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("acceptedForms")
+ @DisplayName("Every accepted form of a timestamp parameter converts to the same Instant")
+ void testToInstantAcceptedForms(final String description, final Object value) {
+ assertEquals(EXPECTED, CassandraCQLExecutionService.toInstant(value));
+ }
+
+ @Test
+ @DisplayName("A null parameter stays null rather than becoming the epoch")
+ void testToInstantIsNullForNull() {
+ assertNull(CassandraCQLExecutionService.toInstant(null));
+ }
+
+ @Test
+ @DisplayName("Text that is neither ISO-8601 nor epoch millis is rejected with the offending value in the message")
+ void testToInstantRejectsUnparseableTextWithAttributableMessage() {
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> CassandraCQLExecutionService.toInstant("not a timestamp"));
+
+ assertTrue(e.getMessage().contains("not a timestamp"), e.getMessage());
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceDriverConfigFileTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceDriverConfigFileTest.java
new file mode 100644
index 000000000000..a37b8eb474e2
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceDriverConfigFileTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
+import com.datastax.oss.driver.api.core.config.DriverConfigLoader;
+import com.datastax.oss.driver.api.core.config.DriverExecutionProfile;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.service.cql.api.service.AbstractCQLExecutionService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit coverage for {@code buildConfigLoader}, the composition behind the {@code Driver Configuration File}
+ * property. The connection verification IT could only assert "a valid file still connects"; this pins the
+ * behaviour a container never exercised - that an unset property is a pass-through, and that a set file is
+ * layered ahead of the property-derived config so its options take effect (both an override and one the
+ * file alone declares).
+ */
+class CassandraCQLExecutionServiceDriverConfigFileTest {
+
+ private final CassandraCQLExecutionService service = new CassandraCQLExecutionService();
+
+ @Test
+ @DisplayName("With no Driver Configuration File set, the property-derived loader is returned unchanged")
+ void testUnsetFileReturnsPropertyLoaderUnchanged() {
+ final PropertyValue fileProperty = mock(PropertyValue.class);
+ when(fileProperty.isSet()).thenReturn(false);
+
+ final ConfigurationContext context = mock(ConfigurationContext.class);
+ when(context.getProperty(AbstractCQLExecutionService.DRIVER_CONFIGURATION_FILE)).thenReturn(fileProperty);
+
+ final DriverConfigLoader propertyLoader = DriverConfigLoader.programmaticBuilder()
+ .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(2))
+ .build();
+
+ assertSame(propertyLoader, service.buildConfigLoader(context, propertyLoader));
+ }
+
+ @Test
+ @DisplayName("A Driver Configuration File is composed ahead of the property loader, so its options take effect")
+ void testFileIsLayeredAheadOfPropertyLoader() throws IOException {
+ final Path configFile = Files.createTempFile("CqlDriverConfig", ".conf");
+ configFile.toFile().deleteOnExit();
+ Files.writeString(configFile, """
+ datastax-java-driver {
+ basic.request.timeout = 15 seconds
+ basic.request.page-size = 1234
+ }
+ """, StandardCharsets.UTF_8);
+
+ final PropertyValue fileProperty = mock(PropertyValue.class);
+ when(fileProperty.isSet()).thenReturn(true);
+ when(fileProperty.evaluateAttributeExpressions()).thenReturn(fileProperty);
+ when(fileProperty.getValue()).thenReturn(configFile.toString());
+
+ final ConfigurationContext context = mock(ConfigurationContext.class);
+ when(context.getProperty(AbstractCQLExecutionService.DRIVER_CONFIGURATION_FILE)).thenReturn(fileProperty);
+
+ final DriverConfigLoader propertyLoader = DriverConfigLoader.programmaticBuilder()
+ .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(2))
+ .build();
+
+ final DriverExecutionProfile profile = service.buildConfigLoader(context, propertyLoader).getInitialConfig().getDefaultProfile();
+
+ assertEquals(Duration.ofSeconds(15), profile.getDuration(DefaultDriverOption.REQUEST_TIMEOUT),
+ "the file's value must override the property-derived one");
+ assertEquals(1234, profile.getInt(DefaultDriverOption.REQUEST_PAGE_SIZE),
+ "an option only the file declares must be in effect");
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceQueryOverridesTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceQueryOverridesTest.java
new file mode 100644
index 000000000000..b88ab3a4d26f
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceQueryOverridesTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import org.apache.nifi.service.cql.api.service.QueryOverrides;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.time.Duration;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/**
+ * Covers how a per-query override falls back to the connection service's configured default.
+ *
+ * Both resolvers have the same three states - no overrides object at all, an overrides object that does not
+ * set this particular field, and one that does - and the first two must behave identically. Tabulating them
+ * is what makes that equivalence visible.
+ */
+public class CassandraCQLExecutionServiceQueryOverridesTest {
+
+ private static final int CONFIGURED_FETCH_SIZE = 100;
+
+ static Stream fetchSizeCases() {
+ return Stream.of(
+ arguments("no overrides supplied", null, CONFIGURED_FETCH_SIZE),
+ arguments("overrides supplied, fetch size not set", new QueryOverrides(null, null), CONFIGURED_FETCH_SIZE),
+ arguments("fetch size overridden", new QueryOverrides(50, null), 50));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("fetchSizeCases")
+ @DisplayName("Fetch size falls back to the configured default unless the query overrides it")
+ void testResolveFetchSize(final String description, final QueryOverrides overrides, final int expected) {
+ assertEquals(expected, CassandraCQLExecutionService.resolveFetchSize(overrides, CONFIGURED_FETCH_SIZE));
+ }
+
+ static Stream timeoutCases() {
+ return Stream.of(
+ arguments("no overrides supplied", null, null),
+ arguments("overrides supplied, timeout not set", new QueryOverrides(null, null), null),
+ arguments("timeout overridden", new QueryOverrides(null, Duration.ofSeconds(5)), Duration.ofSeconds(5)));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("timeoutCases")
+ @DisplayName("Timeout resolves to null unless the query overrides it, leaving the driver's own default in force")
+ void testResolveTimeoutOverride(final String description, final QueryOverrides overrides, final Duration expected) {
+ assertEquals(expected, CassandraCQLExecutionService.resolveTimeoutOverride(overrides));
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceWritePathTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceWritePathTest.java
new file mode 100644
index 000000000000..a2d79d62a93e
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCQLExecutionServiceWritePathTest.java
@@ -0,0 +1,375 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import com.datastax.oss.driver.api.core.CqlIdentifier;
+import com.datastax.oss.driver.api.core.data.UdtValue;
+import com.datastax.oss.driver.api.core.type.DataType;
+import com.datastax.oss.driver.api.core.type.DataTypes;
+import com.datastax.oss.driver.api.core.type.UserDefinedType;
+import com.datastax.oss.driver.internal.core.type.UserDefinedTypeBuilder;
+import org.apache.nifi.record.path.RecordPath;
+import org.apache.nifi.serialization.SimpleRecordSchema;
+import org.apache.nifi.serialization.record.MapRecord;
+import org.apache.nifi.serialization.record.Record;
+import org.apache.nifi.serialization.record.RecordField;
+import org.apache.nifi.serialization.record.RecordFieldType;
+import org.apache.nifi.serialization.record.RecordSchema;
+import org.apache.nifi.service.cql.api.metadata.PrimaryKeyIdentifier;
+import org.apache.nifi.service.cql.api.metadata.QualifiedTableName;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Unit coverage for the parts of the write path that never needed a cluster: statement generation, the
+ * primary-key-override field-name match, and the value conversion {@code convertForCqlType} applies before
+ * {@code bind()}. Some of these started as defects found in Docker-gated ITs that had no business being
+ * there; the type-coercion cases were lifted out of {@code AbstractCqlRecordFieldTypeIT} for the same
+ * reason - a real container proved nothing the driver's own codecs and this class do not.
+ *
+ * Each test asserts the intended behaviour, so it is a regression test the moment the code is
+ * correct and fails if it regresses.
+ */
+public class CassandraCQLExecutionServiceWritePathTest {
+
+ private final CassandraCQLExecutionService service = new CassandraCQLExecutionService();
+
+ private static RecordSchema schemaOf(final RecordField... fields) {
+ return new SimpleRecordSchema(List.of(fields));
+ }
+
+ // ------------------------------------------------------------------ delete() bind markers
+
+ /**
+ * Binding is {@code delete()}'s job, and it can only bind what {@code generateDelete} tells it to: the
+ * ordered list of keys each of the statement's bind markers corresponds to.
+ */
+ @Test
+ @DisplayName("generateDelete reports the keys its bind markers correspond to, so delete() can bind them")
+ public void testGeneratedDeleteReportsItsBindMarkers() {
+ final RecordSchema schema = schemaOf(
+ new RecordField("id", RecordFieldType.INT.getDataType()),
+ new RecordField("region", RecordFieldType.STRING.getDataType()),
+ new RecordField("name", RecordFieldType.STRING.getDataType()));
+ final Record record = new MapRecord(schema, Map.of("id", 7, "region", "us-east", "name", "seven"));
+
+ final CassandraCQLExecutionService.GeneratedResult result =
+ service.generateDelete(new QualifiedTableName("ks", "t"), record, Map.of(), List.of("id", "region"));
+
+ final String cql = result.statement().getQuery();
+ assertTrue(cql.contains(":id") && cql.contains(":region"),
+ () -> "expected a bind marker per delete key, got: " + cql);
+
+ assertEquals(List.of("id", "region"), result.keysUsed(),
+ () -> "the keys backing the bind markers in: " + cql);
+ }
+
+ /**
+ * A delete key resolved only through a {@code primaryKeyOverrides} RecordPath, with no same-named record
+ * field, must still be accepted: {@code generateUpdate} already treats such a key as resolvable from the
+ * override alone, and {@code generateDelete} must behave the same way.
+ */
+ @Test
+ @DisplayName("A delete key supplied only by a primary key override is accepted, not rejected as missing")
+ public void testGeneratedDeleteAcceptsKeyResolvedOnlyByOverride() {
+ final RecordSchema schema = schemaOf(
+ new RecordField("id", RecordFieldType.INT.getDataType()),
+ new RecordField("created", RecordFieldType.TIMESTAMP.getDataType()));
+ final Record record = new MapRecord(schema, Map.of("id", 7));
+
+ // 'created_date' is not a record field - it exists only as a RecordPath override on this table.
+ final Map overrides = Map.of(
+ new PrimaryKeyIdentifier("ks", "t", "created_date"), RecordPath.compile("/created"));
+
+ final CassandraCQLExecutionService.GeneratedResult result =
+ service.generateDelete(new QualifiedTableName("ks", "t"), record, overrides, List.of("id", "created_date"));
+
+ assertEquals(List.of("id", "created_date"), result.keysUsed());
+ assertTrue(result.statement().getQuery().contains(":created_date"),
+ () -> "expected the override-resolved key to get a bind marker, got: " + result.statement().getQuery());
+ }
+
+ // ------------------------------------------------------------------ insert() bind markers
+
+ /**
+ * A primary key column supplied only through a {@code primaryKeyOverrides} RecordPath - with no field of
+ * that name anywhere in the record schema - must still appear in the INSERT's column list and get a bind
+ * marker. Without this, {@code generateInsert} built its columns purely from {@code schema.getFieldNames()}
+ * and a derived partition/clustering column could never be written at all.
+ */
+ @Test
+ @DisplayName("generateInsert includes a primary key override's target column even with no matching schema field")
+ public void testGeneratedInsertIncludesOverrideOnlyColumn() {
+ final RecordSchema schema = schemaOf(new RecordField("id", RecordFieldType.INT.getDataType()));
+
+ // 'msg_date' is not a record field - it exists only as a RecordPath override on this table.
+ final Map overrides = Map.of(
+ new PrimaryKeyIdentifier("ks", "t", "msg_date"), RecordPath.compile("/sent_at"));
+
+ final CassandraCQLExecutionService.GeneratedResult result =
+ service.generateInsert(new QualifiedTableName("ks", "t"), schema, overrides, null, false);
+
+ assertEquals(List.of("id", "msg_date"), result.keysUsed());
+ assertTrue(result.statement().getQuery().contains(":msg_date"),
+ () -> "expected the override-only column to get a bind marker, got: " + result.statement().getQuery());
+ }
+
+ // ----------------------------------------------------------------- UDT null fields
+
+ /**
+ * A UDT field holding a null value has no runtime class to resolve a codec from, so it must be set via
+ * {@code UdtValue.setToNull} rather than through the same codec-lookup path a non-null value uses.
+ */
+ @Test
+ @DisplayName("A UDT with a null field converts instead of failing the codec lookup")
+ public void testUdtWithNullFieldIsConvertible() {
+ final UserDefinedType addressType = new UserDefinedTypeBuilder("ks", "addr")
+ .withField("street", DataTypes.TEXT)
+ .withField("state", DataTypes.TEXT)
+ .withField("zip", DataTypes.INT)
+ .build();
+
+ final Map address = new HashMap<>();
+ address.put("street", "1 Main St");
+ address.put("state", null);
+ address.put("zip", 12345);
+
+ final Object converted = convertForCqlType(address, addressType);
+
+ assertNotNull(converted);
+ assertTrue(converted instanceof UdtValue, () -> "expected a UdtValue, got " + converted.getClass());
+
+ final UdtValue udtValue = (UdtValue) converted;
+ assertEquals("1 Main St", udtValue.getString("street"));
+ assertTrue(udtValue.isNull(CqlIdentifier.fromInternal("state")), "the null field should round-trip as null");
+ assertEquals(12345, udtValue.getInt("zip"));
+ }
+
+ /**
+ * Same defect reached through a nested {@code Record} rather than a raw {@code Map}, since
+ * {@code convertForCqlType} accepts both as representations of a UDT and a record field is the form
+ * {@code PutCQLRecord} actually produces.
+ */
+ @Test
+ @DisplayName("A UDT supplied as a nested Record with a null field converts too")
+ public void testUdtSuppliedAsRecordWithNullFieldIsConvertible() {
+ final UserDefinedType addressType = new UserDefinedTypeBuilder("ks", "addr")
+ .withField("street", DataTypes.TEXT)
+ .withField("state", DataTypes.TEXT)
+ .build();
+
+ final RecordSchema nested = schemaOf(
+ new RecordField("street", RecordFieldType.STRING.getDataType()),
+ new RecordField("state", RecordFieldType.STRING.getDataType()));
+ final Map values = new HashMap<>();
+ values.put("street", "1 Main St");
+ values.put("state", null);
+
+ final Object converted = convertForCqlType(new MapRecord(nested, values), addressType);
+
+ assertTrue(converted instanceof UdtValue, () -> "expected a UdtValue, got " + converted);
+ assertTrue(((UdtValue) converted).isNull(CqlIdentifier.fromInternal("state")));
+ }
+
+ // ------------------------------------------------------------------ value conversion for scalar and collection types
+
+ /**
+ * A {@code timeuuid} column only accepts a genuine version-1 UUID. The driver's own codec would reject a
+ * v4 with a {@code CodecNotFoundException} that reads like a configuration fault, so {@code convertForCqlType}
+ * checks first and fails with the offending value named. Lifted out of {@code AbstractCqlRecordFieldTypeIT},
+ * where a container added nothing to this check.
+ */
+ @Test
+ @DisplayName("A non-version-1 UUID targeting a timeuuid column is rejected, with the offending value in the message")
+ public void testTimeUuidRejectsNonVersion1Uuid() {
+ final UUID v4 = UUID.randomUUID();
+
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> convertForCqlType(v4, DataTypes.TIMEUUID));
+
+ assertTrue(exception.getMessage().contains(v4.toString()),
+ () -> "expected the message to name the offending value, was: " + exception.getMessage());
+ }
+
+ /**
+ * {@code Object[]} is NiFi's canonical ARRAY representation ({@code DataTypeUtils#toArray} always returns
+ * one), so {@code convertForCqlType} accepts it for a list column just as it does a {@code List}, converting
+ * element by element. Also lifted out of {@code AbstractCqlRecordFieldTypeIT}.
+ */
+ @Test
+ @DisplayName("An Object[] targeting a list column is converted element-wise into a List")
+ public void testObjectArrayConvertsForListColumn() {
+ final Object converted = convertForCqlType(new Object[] {"a", "b", "c"}, DataTypes.listOf(DataTypes.TEXT));
+
+ assertEquals(List.of("a", "b", "c"), converted);
+ }
+
+ /**
+ * {@code convertForCqlType} is private and has no package-visible caller that avoids a live session, so it
+ * is reached reflectively rather than by widening the production API purely for a test. A
+ * {@link RuntimeException} it throws (the deliberate {@code IllegalArgumentException} rejections included)
+ * is re-thrown as-is so a test can assert on it; anything else fails the test naming the real cause.
+ */
+ private Object convertForCqlType(final Object value, final DataType cqlType) {
+ try {
+ final Method method = CassandraCQLExecutionService.class
+ .getDeclaredMethod("convertForCqlType", Object.class, DataType.class);
+ method.setAccessible(true);
+ return method.invoke(service, value, cqlType);
+ } catch (final InvocationTargetException e) {
+ final Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException runtimeException) {
+ throw runtimeException;
+ }
+ return fail("convertForCqlType threw " + cause.getClass().getName() + ": " + cause.getMessage(), cause);
+ } catch (final ReflectiveOperationException e) {
+ return fail("could not invoke convertForCqlType", e);
+ }
+ }
+
+ // ------------------------------------------------------------------ override field-name matching
+
+ /**
+ * The override lookup's field-name match must be case-insensitive, via {@code CqlIdentifier} normalization:
+ * a dynamic property name naturally carries whatever case it was typed with (e.g. {@code ks.tbl.MyField}),
+ * while the column it targets is lowercase, unquoted CQL (e.g. {@code myfield}).
+ */
+ @Test
+ @DisplayName("A primary key override declared with a mixed-case field name matches the lowercase column")
+ public void testOverrideMatchIsCaseInsensitiveOnFieldName() {
+ final RecordPath path = RecordPath.compile("/source");
+ final Map overrides =
+ Map.of(new PrimaryKeyIdentifier("ks", "t", "MyField"), path);
+
+ final RecordPath matched = getRecordPathOverride(new QualifiedTableName("ks", "t"), "myfield", overrides);
+
+ assertNotNull(matched, "expected the mixed-case override to match the lowercase column name");
+ assertEquals(path, matched);
+ }
+
+ // ------------------------------------------------------------------ override evaluation
+
+ /**
+ * The value bound for an override-resolved column is whatever the override's {@code RecordPath} selects,
+ * evaluated against the record. A {@code format()} override - the shape that derives a partition or
+ * clustering column from a timestamp field - must yield the formatted {@link String}, which requires
+ * {@code format()} to accept the {@code java.sql.Timestamp} the caller supplies for the source field.
+ */
+ @Test
+ @DisplayName("A format() override derives the expected String from a java.sql.Timestamp source field")
+ public void testEvaluateOverrideDerivesFormattedStringFromTimestamp() {
+ final RecordSchema schema = schemaOf(new RecordField("sent_at", RecordFieldType.TIMESTAMP.getDataType()));
+ final Record record = new MapRecord(schema,
+ Map.of("sent_at", Timestamp.from(Instant.parse("2026-08-01T09:15:00Z"))));
+
+ assertEquals("2026-08-01",
+ evaluateOverride(record, RecordPath.compile("format(/sent_at, 'yyyy-MM-dd', 'UTC')")));
+ assertEquals("9",
+ evaluateOverride(record, RecordPath.compile("format(/sent_at, 'H', 'UTC')")));
+ }
+
+ /**
+ * A RecordPath that selects nothing (a column with no matching field, and no override value to fall back
+ * on) must fail rather than bind null into a primary key column.
+ */
+ @Test
+ @DisplayName("An override whose RecordPath selects no value is rejected")
+ public void testEvaluateOverrideRejectsNoValue() {
+ final RecordSchema schema = schemaOf(
+ new RecordField("tags", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.STRING.getDataType())));
+ final Record record = new MapRecord(schema, Map.of("tags", new Object[] {"a", "b"}));
+
+ // An array index past the end selects nothing at all.
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> evaluateOverride(record, RecordPath.compile("/tags[9]")));
+
+ assertTrue(exception.getMessage().contains("no values"), exception.getMessage());
+ }
+
+ /**
+ * A RecordPath that selects more than one value has no single value to bind, so it must fail rather than
+ * pick one arbitrarily.
+ */
+ @Test
+ @DisplayName("An override whose RecordPath selects more than one value is rejected")
+ public void testEvaluateOverrideRejectsMultipleValues() {
+ final RecordSchema schema = schemaOf(
+ new RecordField("a", RecordFieldType.STRING.getDataType()),
+ new RecordField("b", RecordFieldType.STRING.getDataType()));
+ final Record record = new MapRecord(schema, Map.of("a", "x", "b", "y"));
+
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> evaluateOverride(record, RecordPath.compile("/*")));
+
+ assertTrue(exception.getMessage().contains("more than one value"), exception.getMessage());
+ }
+
+ /**
+ * {@code getRecordPathOverride} is private and touches no session, so it is reached reflectively rather
+ * than by widening production visibility purely for a test.
+ */
+ private RecordPath getRecordPathOverride(final QualifiedTableName tableName, final String fieldName,
+ final Map overrides) {
+ try {
+ final Method method = CassandraCQLExecutionService.class.getDeclaredMethod(
+ "getRecordPathOverride", QualifiedTableName.class, String.class, Map.class);
+ method.setAccessible(true);
+ return (RecordPath) method.invoke(service, tableName, fieldName, overrides);
+ } catch (final InvocationTargetException e) {
+ final Throwable cause = e.getCause();
+ return fail("getRecordPathOverride threw " + cause.getClass().getName() + ": " + cause.getMessage(), cause);
+ } catch (final ReflectiveOperationException e) {
+ return fail("could not invoke getRecordPathOverride", e);
+ }
+ }
+
+ /**
+ * {@code evaluateOverride} is private and touches no session, so it is reached reflectively. A
+ * {@link RuntimeException} it throws is re-thrown as-is so a test can assert on the rejection.
+ */
+ private Object evaluateOverride(final Record record, final RecordPath path) {
+ try {
+ final Method method = CassandraCQLExecutionService.class.getDeclaredMethod(
+ "evaluateOverride", Record.class, RecordPath.class);
+ method.setAccessible(true);
+ return method.invoke(service, record, path);
+ } catch (final InvocationTargetException e) {
+ final Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException runtimeException) {
+ throw runtimeException;
+ }
+ return fail("evaluateOverride threw " + cause.getClass().getName() + ": " + cause.getMessage(), cause);
+ } catch (final ReflectiveOperationException e) {
+ return fail("could not invoke evaluateOverride", e);
+ }
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraConfigOverrides.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraConfigOverrides.java
new file mode 100644
index 000000000000..4fa76296f03b
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraConfigOverrides.java
@@ -0,0 +1,71 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.function.UnaryOperator;
+
+/**
+ * Patches the shared {@code cassandra-base-config/cassandra.yaml} at test runtime rather than requiring a
+ * full near-duplicate copy of that ~250-line file per IT scenario. {@code CassandraContainer#
+ * withConfigurationOverride()} only accepts a classpath resource directory, so the patched result is
+ * written to a temp file and copied into the container directly (as {@code /etc/cassandra/cassandra.yaml})
+ * with {@code withCopyFileToContainer()} instead.
+ */
+final class CassandraConfigOverrides {
+
+ private static final String BASE_CONFIG_RESOURCE = "cassandra-base-config/cassandra.yaml";
+
+ static final String CONTAINER_CASSANDRA_YAML_PATH = "/etc/cassandra/cassandra.yaml";
+
+ private CassandraConfigOverrides() {
+ }
+
+ static Path writePatchedConfig(final UnaryOperator patch) {
+ final String base = readBaseConfig();
+ final String patched = patch.apply(base);
+ if (patched.equals(base)) {
+ throw new IllegalStateException("Patch did not match anything in the base Cassandra config");
+ }
+
+ try {
+ final Path path = Files.createTempFile("cassandra", ".yaml");
+ path.toFile().deleteOnExit();
+ Files.writeString(path, patched, StandardCharsets.UTF_8);
+ return path;
+ } catch (final IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static String readBaseConfig() {
+ try (InputStream in = CassandraConfigOverrides.class.getClassLoader().getResourceAsStream(BASE_CONFIG_RESOURCE)) {
+ if (in == null) {
+ throw new IllegalStateException("Missing classpath resource " + BASE_CONFIG_RESOURCE);
+ }
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (final IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraContainerLimits.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraContainerLimits.java
new file mode 100644
index 000000000000..1947e29e8243
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraContainerLimits.java
@@ -0,0 +1,58 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import org.apache.nifi.service.cql.it.DockerUtils;
+import org.testcontainers.cassandra.CassandraContainer;
+
+/**
+ * The resource ceiling every Cassandra container in this package runs under, so no container in an
+ * integration run can size itself against the whole host. This is the Cassandra counterpart to ScyllaDB's
+ * {@code ScyllaContainerLimits}, and simpler than it: Cassandra needs no equivalent of ScyllaDB's Seastar
+ * memory and commitlog flags, since the JVM reads the cgroup limit itself.
+ *
+ * The CPU allowance is what this module's integration time is sensitive to, not the memory ceiling.
+ * Capping containers at 2 cores cost the module roughly 30 seconds a run, most of it in the SSL/auth
+ * verification suite, which pays Cassandra's CPU-bound startup for a specially-configured container. Measured
+ * on that suite back when it booted a container per scenario: 57.1s at 2 cores, 36.7s at {@value #CPUS},
+ * against 35.7s with no cap at all - so this allowance gives back essentially all of it. Memory turned out
+ * not to matter over the range tried: at {@value #CPUS} cores the same suite ran 36.8s with this
+ * {@value #MEMORY_GB} GB ceiling and 36.7s with 3 GB, so the tighter ceiling is kept.
+ *
+ *
{@value #CPUS} is an allowance rather than a reservation - it bounds a container that would otherwise
+ * see every core on the host, and containers here start one at a time.
+ */
+final class CassandraContainerLimits {
+
+ private static final long CPUS = 3;
+
+ private static final long MEMORY_GB = 2;
+
+ private CassandraContainerLimits() {
+ }
+
+ /**
+ * Caps the container's host resources.
+ *
+ * @param container the container to constrain, before it is started
+ * @return the same container, for chaining
+ */
+ static CassandraContainer apply(final CassandraContainer container) {
+ return container.withCreateContainerCmdModifier(DockerUtils.createMemoryLimits(CPUS, MEMORY_GB));
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCrudIT.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCrudIT.java
new file mode 100644
index 000000000000..f39159e2a303
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraCrudIT.java
@@ -0,0 +1,62 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.service.cql.it.AbstractCqlCrudIT;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.BeforeParameterizedClassInvocation;
+import org.junit.jupiter.params.Parameter;
+import org.junit.jupiter.params.ParameterizedClass;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * CRUD coverage from {@link AbstractCqlCrudIT} run against a real Cassandra container. Runs against every
+ * supported Cassandra major version when {@code -DTEST_CASSANDRA_OLDER_VERSIONS=true} is set; otherwise
+ * only the current major version runs (see {@link CassandraTestVersions}).
+ *
+ *
The container itself belongs to {@link SharedCassandraCluster}, not to this class - see there for what
+ * sharing one costs and requires. This suite owns the {@code testspace} keyspace that {@code init.cql}
+ * creates at container start.
+ */
+@ParameterizedClass
+@MethodSource("org.apache.nifi.service.cassandra.CassandraTestVersions#allVersions")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class CassandraCrudIT extends AbstractCqlCrudIT {
+
+ private static final String KEYSPACE = "testspace";
+
+ // A @ParameterizedClass combined with @TestInstance(PER_CLASS) requires field injection rather than
+ // constructor injection, so this field is what JUnit populates per invocation (per version value).
+ @Parameter
+ private String version;
+
+ // Runs once per invocation of this parameterized class (once per version value), before any @Test
+ // methods. Also responsible for calling initializeSessionProvider() directly (rather than relying on
+ // an inherited @BeforeAll) since @BeforeAll - even inherited from AbstractCqlCrudIT - runs BEFORE this
+ // method on a @ParameterizedClass leaf, not after; see AbstractCqlCrudIT's class javadoc.
+ @BeforeParameterizedClassInvocation
+ void attachToCluster(final String version) throws Exception {
+ initializeSessionProvider(SharedCassandraCluster.forVersion(version).connectionInfo(KEYSPACE));
+ }
+
+ @Override
+ protected CQLExecutionService newSessionProvider() {
+ return new CassandraCQLExecutionService();
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraRecordFieldTypeIT.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraRecordFieldTypeIT.java
new file mode 100644
index 000000000000..6462c4a72c82
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraRecordFieldTypeIT.java
@@ -0,0 +1,53 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.service.cql.it.AbstractCqlRecordFieldTypeIT;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.TestInstance;
+
+/**
+ * Type-coverage tests from {@link AbstractCqlRecordFieldTypeIT} run against a real Cassandra container.
+ * Single fixed version rather than a parameterized matrix, since the behavior under test (record field type
+ * <-> CQL type conversion) doesn't vary across Cassandra major versions the way connection/auth
+ * handling can.
+ *
+ *
The container itself belongs to {@link SharedCassandraCluster}, not to this class: pinning to
+ * {@link SharedCassandraCluster#PINNED_VERSION} means this suite lands on the same instance the
+ * version-parameterized suites use for that version rather than starting one of its own. It owns the
+ * {@code type_coverage} keyspace, in which it creates a table per type under test.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class CassandraRecordFieldTypeIT extends AbstractCqlRecordFieldTypeIT {
+
+ private static final String KEYSPACE = "type_coverage";
+
+ @BeforeAll
+ void attachToCluster() throws Exception {
+ final SharedCassandraCluster cluster = SharedCassandraCluster.forVersion(SharedCassandraCluster.PINNED_VERSION);
+ cluster.createKeyspace(KEYSPACE);
+
+ initializeSessionProvider(cluster.connectionInfo(KEYSPACE));
+ }
+
+ @Override
+ protected CQLExecutionService newSessionProvider() {
+ return new CassandraCQLExecutionService();
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraSecureVerificationIT.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraSecureVerificationIT.java
new file mode 100644
index 000000000000..33900eb1995f
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraSecureVerificationIT.java
@@ -0,0 +1,118 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import com.datastax.oss.driver.api.core.CqlSession;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.service.cql.it.AbstractCqlSecureVerificationIT;
+import org.junit.jupiter.api.TestInstance;
+import org.testcontainers.cassandra.CassandraContainer;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.MountableFile;
+
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.cert.X509Certificate;
+import java.util.function.UnaryOperator;
+
+/**
+ * {@link AbstractCqlSecureVerificationIT} against a single Cassandra 5.0 container configured with
+ * {@code PasswordAuthenticator} and one-way {@code client_encryption_options} in {@code optional: true}
+ * mode, so the {@code init.cql} bootstrap still runs over plaintext. The {@code admin} role is created once
+ * afterwards through the built-in {@code cassandra} superuser. A single fixed version rather than a matrix:
+ * what is exercised here is the driver's auth and SSL wiring, not anything version-specific.
+ */
+@Testcontainers
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class CassandraSecureVerificationIT extends AbstractCqlSecureVerificationIT {
+
+ private static final String CASSANDRA_VERSION = "5.0";
+
+ private static final int CQL_PORT = 9042;
+
+ private static final String KEYSTORE_CONTAINER_PATH = "/etc/cassandra/.keystore";
+
+ // The disabled client_encryption_options block as it appears in cassandra-base-config/cassandra.yaml.
+ private static final String DISABLED_ENCRYPTION_BLOCK =
+ "client_encryption_options:\n\n enabled: false\n\n keystore: conf/.keystore\n\n require_client_auth: false\n";
+
+ @Override
+ protected CQLExecutionService newSessionProvider() {
+ return new CassandraCQLExecutionService();
+ }
+
+ @Override
+ protected SecureServer startSecureServer(final KeyPair serverKeyPair, final X509Certificate serverCertificate,
+ final String adminRole, final String adminPassword) throws Exception {
+ final SslKeyStoreCredentials serverKeyStore = writeKeyStore(serverKeyPair.getPrivate(), serverCertificate, "server");
+
+ final CassandraContainer container = CassandraContainerLimits.apply(new CassandraContainer("cassandra:" + CASSANDRA_VERSION))
+ .withCopyFileToContainer(MountableFile.forHostPath(writePatchedConfig(serverKeyStore)),
+ CassandraConfigOverrides.CONTAINER_CASSANDRA_YAML_PATH)
+ .withCopyFileToContainer(MountableFile.forHostPath(serverKeyStore.path()), KEYSTORE_CONTAINER_PATH)
+ .withInitScript("init.cql");
+ container.withExposedPorts(CQL_PORT);
+ container.start();
+
+ // init.cql runs over the optional-plaintext listener as the built-in "cassandra" superuser, which
+ // Testcontainers also uses; reuse it once to create the real, generated-password admin role.
+ try (CqlSession bootstrapSession = CqlSession.builder()
+ .addContactPoint(container.getContactPoint())
+ .withLocalDatacenter(LOCAL_DATACENTER)
+ .withAuthCredentials(container.getUsername(), container.getPassword())
+ .build()) {
+ bootstrapSession.execute(String.format(
+ "CREATE ROLE %s WITH PASSWORD = '%s' AND LOGIN = true", adminRole, adminPassword));
+ }
+
+ final String contactPoint = container.getContainerIpAddress() + ":" + container.getMappedPort(CQL_PORT);
+ return new SecureServer(contactPoint, container::stop);
+ }
+
+ /**
+ * Patches {@code cassandra-base-config/cassandra.yaml} to switch on {@code PasswordAuthenticator} and an
+ * enabled, one-way, optional {@code client_encryption_options} pointing at the mounted server keystore -
+ * rather than checking in a near-duplicate of that ~250-line file.
+ */
+ private static Path writePatchedConfig(final SslKeyStoreCredentials serverKeyStore) {
+ final UnaryOperator enableAuth = requirePatched("authenticator",
+ config -> config.replace("authenticator: AllowAllAuthenticator", "authenticator: PasswordAuthenticator"));
+
+ final String enabledEncryptionBlock = "client_encryption_options:\n"
+ + " enabled: true\n"
+ + " optional: true\n"
+ + " keystore: " + KEYSTORE_CONTAINER_PATH + "\n"
+ + " keystore_password: " + serverKeyStore.password() + "\n"
+ + " require_client_auth: false\n"
+ + " store_type: " + STORE_TYPE + "\n";
+ final UnaryOperator enableEncryption = requirePatched("client_encryption_options",
+ config -> config.replace(DISABLED_ENCRYPTION_BLOCK, enabledEncryptionBlock));
+
+ return CassandraConfigOverrides.writePatchedConfig(config -> enableEncryption.apply(enableAuth.apply(config)));
+ }
+
+ private static UnaryOperator requirePatched(final String what, final UnaryOperator patch) {
+ return config -> {
+ final String patched = patch.apply(config);
+ if (patched.equals(config)) {
+ throw new IllegalStateException("The " + what + " patch matched nothing in the base Cassandra config");
+ }
+ return patched;
+ };
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraTableMetadataMappingTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraTableMetadataMappingTest.java
new file mode 100644
index 000000000000..647802ffa70f
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraTableMetadataMappingTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import com.datastax.oss.driver.api.core.CqlIdentifier;
+import com.datastax.oss.driver.api.core.metadata.schema.ClusteringOrder;
+import com.datastax.oss.driver.api.core.metadata.schema.ColumnMetadata;
+import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata;
+import org.apache.nifi.service.cql.api.constants.PrimaryKeyFieldType;
+import org.apache.nifi.service.cql.api.metadata.PrimaryKey;
+import org.apache.nifi.service.cql.api.metadata.PrimaryKeyMetadata;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit coverage for the driver-metadata-to-{@link PrimaryKey} mapping behind {@code getMetadata}. It walks
+ * {@code TableMetadata}'s partition-key list and clustering-column map and records each column's name,
+ * position within its own group, and role - no cluster required. The CRUD integration suite used to carry
+ * this by reading real tables back; a fabricated {@code TableMetadata} pins the same properties (membership,
+ * role, and declaration order) without a container.
+ */
+class CassandraTableMetadataMappingTest {
+
+ private final CassandraCQLExecutionService service = new CassandraCQLExecutionService();
+
+ @Test
+ @DisplayName("A table with no clustering columns maps to a single PARTITION entry and an empty clustering list")
+ void testPartitionKeyOnlyTable() {
+ // Column mocks are built into locals first: stubbing one inside the argument of another when(...)
+ // trips Mockito's unfinished-stubbing check.
+ final ColumnMetadata username = column("username");
+
+ final TableMetadata metadata = mock(TableMetadata.class);
+ when(metadata.getPartitionKey()).thenReturn(List.of(username));
+ when(metadata.getClusteringColumns()).thenReturn(Map.of());
+
+ final PrimaryKey primaryKey = convertTableMetadata(metadata);
+
+ assertEquals(List.of(new PrimaryKeyMetadata("username", 0, PrimaryKeyFieldType.PARTITION)),
+ primaryKey.partitionKey());
+ assertTrue(primaryKey.clusteringKeys().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Clustering columns keep the table's declaration order, each numbered from zero within its group")
+ void testClusteringColumnsInDeclarationOrder() {
+ final ColumnMetadata sender = column("sender");
+ final ColumnMetadata receiver = column("receiver");
+ final ColumnMetadata whenSent = column("when_sent");
+
+ final Map clusteringColumns = new LinkedHashMap<>();
+ clusteringColumns.put(receiver, ClusteringOrder.ASC);
+ clusteringColumns.put(whenSent, ClusteringOrder.ASC);
+
+ final TableMetadata metadata = mock(TableMetadata.class);
+ when(metadata.getPartitionKey()).thenReturn(List.of(sender));
+ when(metadata.getClusteringColumns()).thenReturn(clusteringColumns);
+
+ final PrimaryKey primaryKey = convertTableMetadata(metadata);
+
+ assertEquals(List.of(new PrimaryKeyMetadata("sender", 0, PrimaryKeyFieldType.PARTITION)),
+ primaryKey.partitionKey());
+ assertEquals(List.of(
+ new PrimaryKeyMetadata("receiver", 0, PrimaryKeyFieldType.CLUSTERING),
+ new PrimaryKeyMetadata("when_sent", 1, PrimaryKeyFieldType.CLUSTERING)),
+ primaryKey.clusteringKeys());
+ }
+
+ private static ColumnMetadata column(final String name) {
+ final ColumnMetadata column = mock(ColumnMetadata.class);
+ when(column.getName()).thenReturn(CqlIdentifier.fromInternal(name));
+ return column;
+ }
+
+ /**
+ * {@code convertTableMetadata} is private and its only caller needs a live session, so it is reached
+ * reflectively rather than by widening production visibility purely for a test - the same approach
+ * {@link CassandraCQLExecutionServiceWritePathTest} takes.
+ */
+ private PrimaryKey convertTableMetadata(final TableMetadata metadata) {
+ try {
+ final Method method = CassandraCQLExecutionService.class.getDeclaredMethod("convertTableMetadata", TableMetadata.class);
+ method.setAccessible(true);
+ return (PrimaryKey) method.invoke(service, metadata);
+ } catch (final InvocationTargetException e) {
+ final Throwable cause = e.getCause();
+ return fail("convertTableMetadata threw " + cause.getClass().getName() + ": " + cause.getMessage(), cause);
+ } catch (final ReflectiveOperationException e) {
+ return fail("could not invoke convertTableMetadata", e);
+ }
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraTestVersions.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraTestVersions.java
new file mode 100644
index 000000000000..b08e7fe1c975
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CassandraTestVersions.java
@@ -0,0 +1,48 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import java.util.stream.Stream;
+
+/**
+ * Supplies the Cassandra major versions that the {@code @ParameterizedClass} integration tests in this
+ * package run against. Cassandra 3.11 and 4.1 containers are slow to start and are no longer receiving
+ * upstream support, so they're only exercised when the {@code TEST_CASSANDRA_OLDER_VERSIONS} system
+ * property is set to {@code true}; otherwise only the current major version (5.0) runs.
+ */
+final class CassandraTestVersions {
+
+ private static final String RUN_OLDER_VERSIONS_PROPERTY = "TEST_CASSANDRA_OLDER_VERSIONS";
+
+ /**
+ * The current major version, and the only one exercised unless {@code TEST_CASSANDRA_OLDER_VERSIONS} is
+ * set. Suites whose behaviour does not vary by release pin to this rather than declaring a version of
+ * their own, so that they share {@code SharedCassandraCluster}'s container for it instead of silently
+ * starting a second one the day this is bumped.
+ */
+ static final String CURRENT_VERSION = "5.0";
+
+ private CassandraTestVersions() {
+ }
+
+ static Stream allVersions() {
+ return Boolean.getBoolean(RUN_OLDER_VERSIONS_PROPERTY)
+ ? Stream.of("3.11", "4.1", CURRENT_VERSION)
+ : Stream.of(CURRENT_VERSION);
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CqlConsistencyLevelTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CqlConsistencyLevelTest.java
new file mode 100644
index 000000000000..a6b00d5e4bf8
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CqlConsistencyLevelTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import com.datastax.oss.driver.api.core.DefaultConsistencyLevel;
+import org.apache.nifi.service.cql.api.constants.CqlConsistencyLevel;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * Pins {@link CqlConsistencyLevel}'s values against the DataStax driver's own enum.
+ *
+ * {@code CqlConsistencyLevel} deliberately declares these names itself rather than deriving them from the
+ * driver, so that {@code nifi-cql-services-api} - the backend-agnostic contract every other module compiles
+ * against - carries no dependency on a database client library. The cost of that choice is that nothing in the
+ * type system keeps the two in step, and the selected value is handed to the driver as a plain string:
+ * {@code CassandraCQLExecutionService} sets it as {@code DefaultDriverOption.REQUEST_CONSISTENCY}, which the
+ * driver resolves with {@code DefaultConsistencyLevel.valueOf}. A name the driver cannot parse therefore fails
+ * at runtime on a configured service rather than at build time - which is what this test exists to prevent.
+ *
+ *
This test lives here, in the module that already depends on the driver, rather than beside the enum:
+ * {@code nifi-cql-services-api} is the module the {@code ban-database-client-dependencies} enforcer rule
+ * guards, and that rule has no scope exemption, so even a test-scoped driver dependency there would fail the
+ * build. {@code ScyllaConsistencyLevelTest} makes the same assertions against ScyllaDB's fork, which ships its
+ * own copy of the driver enum.
+ */
+class CqlConsistencyLevelTest {
+
+ @ParameterizedTest
+ @EnumSource(CqlConsistencyLevel.class)
+ @DisplayName("Every declared level's value is a name the DataStax driver can parse, since it is passed straight through as a config string")
+ void testValueParsesAsDriverConsistencyLevel(final CqlConsistencyLevel level) {
+ assertDoesNotThrow(() -> DefaultConsistencyLevel.valueOf(level.getValue()),
+ () -> level.name() + " declares the value '" + level.getValue()
+ + "', which the driver does not recognise. Known levels: "
+ + Arrays.stream(DefaultConsistencyLevel.values())
+ .map(Enum::name)
+ .collect(Collectors.joining(", ")));
+ }
+
+ @ParameterizedTest
+ @EnumSource(CqlConsistencyLevel.class)
+ @DisplayName("The declared value matches the driver level it resolves to, so the two enums cannot drift apart silently")
+ void testValueMatchesTheResolvedDriverLevel(final CqlConsistencyLevel level) {
+ assertEquals(level.getValue(), DefaultConsistencyLevel.valueOf(level.getValue()).name());
+ }
+
+ // Deliberately one-directional: exposing a subset of the driver's levels is a product decision, so a level
+ // the driver gains but this enum does not offer is not a failure. The direction that matters is that
+ // everything offered in the UI works.
+
+ @ParameterizedTest
+ @EnumSource(CqlConsistencyLevel.class)
+ @DisplayName("Every level carries a display name and a description, which are what the Consistency Level dropdown renders")
+ void testDescribedValueContractIsPopulated(final CqlConsistencyLevel level) {
+ assertFalse(level.getDisplayName() == null || level.getDisplayName().isBlank(),
+ () -> level.name() + " has no display name");
+ assertFalse(level.getDescription() == null || level.getDescription().isBlank(),
+ () -> level.name() + " has no description; the dropdown would show a bare name");
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CqlRowAndCellTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CqlRowAndCellTest.java
new file mode 100644
index 000000000000..1a2f70d47e64
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/CqlRowAndCellTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import org.apache.nifi.service.cql.api.lookup.CqlCell;
+import org.apache.nifi.service.cql.api.lookup.CqlRow;
+import org.apache.nifi.service.cql.api.lookup.impl.StandardCqlCell;
+import org.apache.nifi.service.cql.api.lookup.impl.StandardCqlRow;
+import org.apache.nifi.service.cql.api.lookup.impl.StandardCqlStatementResult;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Covers the schema-free row and cell types: the behaviour a caller relies on that is not simply the record
+ * components handed back.
+ */
+class CqlRowAndCellTest {
+
+ private static byte[] utf8(final String value) {
+ return value.getBytes(StandardCharsets.UTF_8);
+ }
+
+ // ---- Cell -----------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("A ByteBuffer value is copied out as bytes without consuming the buffer the backend handed over")
+ void testByteBufferIsReadWithoutConsumingIt() {
+ final ByteBuffer shared = ByteBuffer.wrap(utf8("hello"));
+ final CqlCell cell = new StandardCqlCell("v", shared);
+
+ assertArrayEquals(utf8("hello"), cell.getBytes());
+ assertEquals(5, shared.remaining(), "reading the cell must not advance the backend's buffer");
+ assertArrayEquals(utf8("hello"), cell.getBytes(), "a second read must return the same bytes");
+ }
+
+ @Test
+ @DisplayName("A byte[] value is copied, so a caller mutating the result cannot corrupt the cell")
+ void testByteArrayIsCopied() {
+ final byte[] original = utf8("hello");
+ final CqlCell cell = new StandardCqlCell("v", original);
+
+ final byte[] returned = cell.getBytes();
+ returned[0] = 'J';
+
+ assertArrayEquals(utf8("hello"), cell.getBytes());
+ }
+
+ @Test
+ @DisplayName("A null cell reports null rather than an empty array, so absent and empty stay distinguishable")
+ void testNullValue() {
+ final CqlCell cell = new StandardCqlCell("v", null);
+
+ assertTrue(cell.isNull());
+ assertNull(cell.getBytes());
+ assertNull(cell.getObject());
+ }
+
+ @Test
+ @DisplayName("Asking a non-binary cell for bytes fails rather than inventing an encoding")
+ void testGetBytesOnNonBinaryColumn() {
+ final CqlCell cell = new StandardCqlCell("n", 42);
+
+ final UnsupportedOperationException thrown = assertThrows(UnsupportedOperationException.class, cell::getBytes);
+ assertTrue(thrown.getMessage().contains("n"), thrown.getMessage());
+ }
+
+ @Test
+ @DisplayName("A column whose CQL type has no JDK form throws on read, naming the type and the alternative")
+ void testUnsupportedColumnType() {
+ final CqlCell cell = StandardCqlCell.unsupported("address", "address_type");
+
+ assertFalse(cell.isNull(), "an unreadable cell is not the same as a null one");
+ final UnsupportedOperationException thrown = assertThrows(UnsupportedOperationException.class, cell::getObject);
+ assertTrue(thrown.getMessage().contains("address_type"), thrown.getMessage());
+ assertTrue(thrown.getMessage().contains("query"), "should point at the API that can read it: " + thrown.getMessage());
+ }
+
+ // ---- Row ------------------------------------------------------------------------------------------
+
+ private static CqlRow row() {
+ return new StandardCqlRow(List.of(
+ new StandardCqlCell("k", ByteBuffer.wrap(utf8("key"))),
+ new StandardCqlCell("writetime(v)", 1234L),
+ new StandardCqlCell("v", ByteBuffer.wrap(utf8("value")))));
+ }
+
+ @Test
+ @DisplayName("Cells come back in selection order, which is the only ordering information a schema-free caller has")
+ void testCellsPreserveSelectionOrder() {
+ assertEquals(List.of("k", "writetime(v)", "v"), row().columnNames());
+ }
+
+ @Test
+ @DisplayName("A column name that is not a legal CQL identifier is carried through unchanged")
+ void testIllegalIdentifierColumnNameIsPreserved() {
+ assertEquals(1234L, row().getObject("writetime(v)"));
+ }
+
+ @Test
+ @DisplayName("Looking up a column that is not in the row fails and says what the row does have")
+ void testMissingColumnThrows() {
+ final IllegalArgumentException thrown =
+ assertThrows(IllegalArgumentException.class, () -> row().getBytes("nope"));
+
+ assertTrue(thrown.getMessage().contains("nope"), thrown.getMessage());
+ assertTrue(thrown.getMessage().contains("writetime(v)"), "should list the actual columns: " + thrown.getMessage());
+ }
+
+ @Test
+ @DisplayName("A duplicated column name keeps both cells, which is why a row is a list rather than a map")
+ void testDuplicateColumnNamesAreBothRetained() {
+ final CqlRow duplicated = new StandardCqlRow(List.of(
+ new StandardCqlCell("v", ByteBuffer.wrap(utf8("first"))),
+ new StandardCqlCell("v", ByteBuffer.wrap(utf8("second")))));
+
+ assertEquals(2, duplicated.cells().size());
+ assertEquals(List.of("v", "v"), duplicated.columnNames());
+ // The by-name shorthand can only answer with one of them, which is precisely why cells() exists.
+ assertArrayEquals(utf8("first"), duplicated.getBytes("v"));
+ assertArrayEquals(utf8("second"), duplicated.cells().get(1).getBytes());
+ }
+
+ @Test
+ @DisplayName("findCell distinguishes an absent column from a present one holding null")
+ void testFindCellSeparatesAbsentFromNull() {
+ final CqlRow withNull = new StandardCqlRow(List.of(new StandardCqlCell("v", null)));
+
+ assertTrue(withNull.findCell("v").isPresent(), "the column is present");
+ assertNull(withNull.getBytes("v"), "...and holds a null");
+ assertTrue(withNull.findCell("absent").isEmpty());
+ }
+
+ @Test
+ @DisplayName("A row's cells cannot be changed through the list handed to it or the list handed back")
+ void testCellsAreImmutable() {
+ assertThrows(UnsupportedOperationException.class, () -> row().cells().clear());
+ }
+
+ // ---- Result ---------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("An applied conditional write reports true and carries no row, since there was nothing to return")
+ void testAppliedResult() {
+ final StandardCqlStatementResult result = new StandardCqlStatementResult(true, List.of());
+
+ assertTrue(result.wasApplied());
+ assertTrue(result.rows().isEmpty());
+ }
+
+ @Test
+ @DisplayName("A rejected conditional write reports false and carries the row that is actually stored")
+ void testRejectedResultCarriesCurrentRow() {
+ final StandardCqlStatementResult result = new StandardCqlStatementResult(false, List.of(row()));
+
+ assertFalse(result.wasApplied());
+ assertArrayEquals(utf8("value"), result.rows().getFirst().getBytes("v"));
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/SharedCassandraCluster.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/SharedCassandraCluster.java
new file mode 100644
index 000000000000..56b0e602caad
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/SharedCassandraCluster.java
@@ -0,0 +1,114 @@
+/*
+ * 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 org.apache.nifi.service.cassandra;
+
+import com.datastax.oss.driver.api.core.CqlSession;
+import org.apache.nifi.service.cql.it.CqlConnectionInfo;
+import org.apache.nifi.service.cql.it.CqlDdl;
+import org.testcontainers.cassandra.CassandraContainer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * One Cassandra container per major version, shared by every IT in this package that needs only a plain,
+ * default-configured server (CRUD, record field types), sparing the integration run a container start
+ * apiece for suites that otherwise boot an identical server.
+ *
+ *
Keyed by version rather than a plain singleton because {@code CassandraCrudIT} is
+ * {@code @ParameterizedClass} over {@link CassandraTestVersions} while {@code CassandraRecordFieldTypeIT}
+ * is pinned to one version: the pinned suite asks for {@value #PINNED_VERSION} and lands on the same
+ * instance the parameterized suite uses for that version. So it is one container per version rather than
+ * one per suite per version, which matters under {@code -DTEST_CASSANDRA_OLDER_VERSIONS=true}.
+ *
+ *
Sharing makes keyspace separation load-bearing: {@code testspace} (from {@code init.cql} at container
+ * start) belongs to the CRUD suite, {@code type_coverage} to {@code CassandraRecordFieldTypeIT} via
+ * {@link #createKeyspace}. A suite must not touch another's keyspace, and any suite asserting on an exact
+ * row or counter value must be that table's only writer.
+ *
+ *
The container is deliberately never stopped - Testcontainers' Ryuk sidecar removes it on JVM exit,
+ * which is the documented way to share one; an explicit {@code stop()} would pull it out from under suites
+ * that have not run yet. The trade-off for a multi-version run: every version's container stays up once
+ * touched, so peak memory is their sum.
+ */
+final class SharedCassandraCluster {
+
+ /**
+ * The version suites that do not vary by release pin to. Taken from {@link CassandraTestVersions} so
+ * bumping the current major cannot leave them starting a container nothing else asks for.
+ */
+ static final String PINNED_VERSION = CassandraTestVersions.CURRENT_VERSION;
+
+ private static final String DATACENTER = "datacenter1";
+
+ private static final int CQL_PORT = 9042;
+
+ private static final Map BY_VERSION = new HashMap<>();
+
+ private final String contactPoint;
+
+ private final CqlSession session;
+
+ private SharedCassandraCluster(final String contactPoint, final CqlSession session) {
+ this.contactPoint = contactPoint;
+ this.session = session;
+ }
+
+ /**
+ * The cluster for {@code version}, started on first request and reused thereafter. Synchronized because
+ * the map operation spans a container start.
+ */
+ static synchronized SharedCassandraCluster forVersion(final String version) {
+ return BY_VERSION.computeIfAbsent(version, SharedCassandraCluster::start);
+ }
+
+ private static SharedCassandraCluster start(final String version) {
+ // init.cql creates the "testspace" keyspace and its tables, which the CRUD and connection
+ // verification suites both expect to exist before their first test.
+ final CassandraContainer container = CassandraContainerLimits.apply(new CassandraContainer("cassandra:" + version))
+ .withTmpFs(Map.of("/var/lib/cassandra", "rw,size=1g"))
+ .withInitScript("init.cql");
+ container.withExposedPorts(CQL_PORT);
+ container.start();
+
+ final CqlSession session = CqlSession.builder()
+ .addContactPoint(container.getContactPoint())
+ .withLocalDatacenter(DATACENTER)
+ .build();
+
+ return new SharedCassandraCluster(
+ container.getContainerIpAddress() + ":" + container.getMappedPort(CQL_PORT), session);
+ }
+
+ /**
+ * Creates {@code keyspace} if absent, for a suite that owns one rather than the {@code init.cql}
+ * keyspace. Idempotent because the container outlives whichever suite got there first.
+ */
+ void createKeyspace(final String keyspace) {
+ CqlDdl.executeWithRetry(session, "create keyspace if not exists " + keyspace
+ + " with replication = {'class': 'SimpleStrategy', 'replication_factor': 1}");
+ }
+
+ CqlConnectionInfo connectionInfo(final String keyspace) {
+ return new CqlConnectionInfo(contactPoint, DATACENTER, keyspace, session);
+ }
+
+ CqlSession session() {
+ return session;
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/CassandraUdtSchemaMapperTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/CassandraUdtSchemaMapperTest.java
new file mode 100644
index 000000000000..b922527684ca
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/CassandraUdtSchemaMapperTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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 org.apache.nifi.service.cassandra.mapping;
+
+import com.datastax.oss.driver.api.core.type.DataType;
+import com.datastax.oss.driver.api.core.type.DataTypes;
+import com.datastax.oss.driver.internal.core.type.DefaultTupleType;
+import com.datastax.oss.driver.internal.core.type.UserDefinedTypeBuilder;
+import org.apache.avro.LogicalTypes;
+import org.apache.avro.Schema;
+import org.apache.nifi.avro.AvroTypeUtil;
+import org.apache.nifi.serialization.record.RecordFieldType;
+import org.apache.nifi.serialization.record.RecordSchema;
+import org.apache.nifi.serialization.record.type.RecordDataType;
+import org.apache.nifi.serialization.record.util.DataTypeUtils;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The Avro schema {@link CassandraUdtSchemaMapper} declares for a column, resolved through
+ * {@link AvroTypeUtil#determineDataType} into the {@link RecordFieldType} a reader will actually see.
+ *
+ * The service places the driver's own decoded value into the record unchanged, so these tests double as a
+ * guard on the pairing between declared type and runtime value. Only UUID/TIMEUUID get a native record type:
+ * the driver decodes those to {@code java.util.UUID}, which the native UUID type accepts directly. The
+ * temporal and arbitrary-precision types stay STRING deliberately - see
+ * {@link #timestampStaysStringBecauseTheNativeTypeWouldRejectTheDriverValue()} and
+ * {@link #timeStaysStringBecauseTheNativeTypeWouldTruncateNanoseconds()} for the two concrete reasons, both
+ * of which are worse than the declared/runtime type difference they would be fixing.
+ *
+ *
A CQL type with no direct Avro equivalent (DURATION, TUPLE, VECTOR, ...) must still produce a schema -
+ * not throw and fail the whole query over one exotic column.
+ */
+public class CassandraUdtSchemaMapperTest {
+
+ private RecordFieldType resolvedFieldType(final DataType cqlType) {
+ final Schema avroSchema = CassandraUdtSchemaMapper.toAvroSchema(cqlType, new HashMap<>());
+ return AvroTypeUtil.determineDataType(avroSchema).getFieldType();
+ }
+
+ /**
+ * The native TIMESTAMP record type is backed by {@code java.sql.Timestamp}, and NiFi's own field
+ * converter rejects the {@code java.time.Instant} the driver decodes a CQL timestamp into. Declaring the
+ * native type here would turn a working, lossless string coercion into a hard conversion failure.
+ */
+ @Test
+ @DisplayName("TIMESTAMP resolves to the STRING record type, which accepts the driver's Instant")
+ public void timestampStaysStringBecauseTheNativeTypeWouldRejectTheDriverValue() {
+ assertEquals(RecordFieldType.STRING, resolvedFieldType(DataTypes.TIMESTAMP));
+
+ final Instant driverValue = Instant.parse("2026-08-08T19:15:09.621Z");
+ assertTrue(DataTypeUtils.isCompatibleDataType(driverValue, RecordFieldType.STRING.getDataType()),
+ "a STRING field must accept the Instant the driver decodes a CQL timestamp into");
+ assertFalse(DataTypeUtils.isCompatibleDataType(driverValue, RecordFieldType.TIMESTAMP.getDataType()),
+ "if the native TIMESTAMP type ever starts accepting Instant, revisit declaring it here");
+ }
+
+ @Test
+ @DisplayName("DATE resolves to the STRING record type, which accepts the driver's LocalDate")
+ public void testDateStaysString() {
+ assertEquals(RecordFieldType.STRING, resolvedFieldType(DataTypes.DATE));
+ assertTrue(DataTypeUtils.isCompatibleDataType(LocalDate.of(2024, 3, 15), RecordFieldType.STRING.getDataType()));
+ }
+
+ /**
+ * CQL {@code time} is nanosecond-resolution and {@code java.sql.Time} - what the native TIME record type
+ * is backed by - is not. Declaring the native type, or converting the value to suit it, silently
+ * truncates. The STRING coercion keeps every digit.
+ */
+ @Test
+ @DisplayName("TIME resolves to the STRING record type, which preserves nanosecond precision")
+ public void timeStaysStringBecauseTheNativeTypeWouldTruncateNanoseconds() {
+ assertEquals(RecordFieldType.STRING, resolvedFieldType(DataTypes.TIME));
+
+ final LocalTime nanoPrecision = LocalTime.of(15, 14, 54, 899_676_065);
+ final Object asString = DataTypeUtils.convertType(
+ nanoPrecision, RecordFieldType.STRING.getDataType(), Optional.empty(), Optional.empty(), Optional.empty(), "value_field");
+ assertEquals("15:14:54.899676065", asString, "the STRING coercion must not drop sub-second precision");
+
+ final Object asNativeTime = DataTypeUtils.convertType(
+ nanoPrecision, RecordFieldType.TIME.getDataType(), Optional.empty(), Optional.empty(), Optional.empty(), "value_field");
+ assertEquals("15:14:54", asNativeTime.toString(),
+ "documents why the native TIME type is not used - it truncates to whole seconds");
+ }
+
+ @Test
+ @DisplayName("UUID resolves to the native UUID record type")
+ public void testUuidResolvesToUuidType() {
+ assertEquals(RecordFieldType.UUID, resolvedFieldType(DataTypes.UUID));
+ }
+
+ @Test
+ @DisplayName("TIMEUUID resolves to the native UUID record type")
+ public void testTimeUuidResolvesToUuidType() {
+ assertEquals(RecordFieldType.UUID, resolvedFieldType(DataTypes.TIMEUUID));
+ }
+
+ @Test
+ @DisplayName("DECIMAL resolves to the STRING record type, not a fixed-precision decimal type")
+ public void testDecimalStaysString() {
+ assertEquals(RecordFieldType.STRING, resolvedFieldType(DataTypes.DECIMAL));
+ }
+
+ @Test
+ @DisplayName("VARINT resolves to the STRING record type")
+ public void testVarintStaysString() {
+ assertEquals(RecordFieldType.STRING, resolvedFieldType(DataTypes.VARINT));
+ }
+
+ @Test
+ @DisplayName("INET resolves to the STRING record type")
+ public void testInetStaysString() {
+ assertEquals(RecordFieldType.STRING, resolvedFieldType(DataTypes.INET));
+ }
+
+ @Test
+ @DisplayName("A CQL type with no Avro equivalent (DURATION) still produces a schema instead of throwing")
+ public void testDurationDoesNotThrow() {
+ final RecordFieldType fieldType = assertDoesNotThrow(() -> resolvedFieldType(DataTypes.DURATION));
+ assertEquals(RecordFieldType.STRING, fieldType);
+ }
+
+ @Test
+ @DisplayName("A compound CQL type with no Avro equivalent (TUPLE) still produces a schema instead of throwing")
+ public void testTupleDoesNotThrow() {
+ final DataType tupleType = new DefaultTupleType(List.of(DataTypes.INT, DataTypes.TEXT));
+ final RecordFieldType fieldType = assertDoesNotThrow(() -> resolvedFieldType(tupleType));
+ assertEquals(RecordFieldType.STRING, fieldType);
+ }
+
+ @Test
+ @DisplayName("A UDT field resolves to the same record type a top-level column of that CQL type would")
+ public void testUdtFieldResolvesTheSameWayAsATopLevelColumn() {
+ final DataType udtType = new UserDefinedTypeBuilder("ks", "event")
+ .withField("event_id", DataTypes.UUID)
+ .withField("occurred_at", DataTypes.TIMESTAMP)
+ .build();
+
+ final Schema avroSchema = CassandraUdtSchemaMapper.toAvroSchema(udtType, new HashMap<>());
+ final org.apache.nifi.serialization.record.DataType recordDataType = AvroTypeUtil.determineDataType(avroSchema);
+
+ assertEquals(RecordFieldType.RECORD, recordDataType.getFieldType());
+ final RecordSchema nested = ((RecordDataType) recordDataType).getChildSchema();
+ assertEquals(RecordFieldType.UUID, nested.getField("event_id").orElseThrow().getDataType().getFieldType());
+ assertEquals(RecordFieldType.STRING, nested.getField("occurred_at").orElseThrow().getDataType().getFieldType());
+ }
+
+ @Test
+ @DisplayName("The uuid logical type is attached, matching how AvroTypeUtil's own Record-to-Avro writer declares a UUID field")
+ public void testUuidLogicalTypeMatchesAvroTypeUtilConvention() {
+ final Schema avroSchema = CassandraUdtSchemaMapper.toAvroSchema(DataTypes.UUID, new HashMap<>());
+ final Schema nonNullBranch = avroSchema.getTypes().stream()
+ .filter(s -> s.getType() != Schema.Type.NULL)
+ .findFirst().orElseThrow();
+ assertEquals(LogicalTypes.uuid().getName(), nonNullBranch.getLogicalType().getName());
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/CharacterCodecTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/CharacterCodecTest.java
new file mode 100644
index 000000000000..23603f9e9252
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/CharacterCodecTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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 org.apache.nifi.service.cassandra.mapping;
+
+import com.datastax.oss.driver.api.core.ProtocolVersion;
+import com.datastax.oss.driver.api.core.type.codec.TypeCodecs;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.nio.ByteBuffer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Covers {@code CharacterCodec}, which lets a NiFi CHAR record field bind to a CQL {@code text} column by
+ * treating the character as a one-character string.
+ *
+ *
The interesting direction is decode: {@code text} can hold the empty string, which has no {@code Character}
+ * to decode to. The codec answers null rather than throwing, and that choice is what these tests pin.
+ */
+class CharacterCodecTest {
+
+ private static final ProtocolVersion VERSION = ProtocolVersion.DEFAULT;
+
+ private final CharacterCodec codec = new CharacterCodec();
+
+ @ParameterizedTest(name = "''{0}''")
+ @ValueSource(chars = {'a', 'Z', '0', ' ', '\'', 'é', '☃'})
+ @DisplayName("A character encodes exactly as the one-character string it represents")
+ void testEncodesAsOneCharacterString(final char value) {
+ assertEquals(TypeCodecs.TEXT.encode(String.valueOf(value), VERSION), codec.encode(value, VERSION));
+ }
+
+ @ParameterizedTest(name = "''{0}''")
+ @ValueSource(chars = {'a', 'Z', '0', ' ', '\'', 'é', '☃'})
+ @DisplayName("A character round-trips back to the same character")
+ void testRoundTrip(final char value) {
+ assertEquals(value, codec.decode(codec.encode(value, VERSION), VERSION));
+ }
+
+ @Test
+ @DisplayName("A null character encodes to null and formats as the CQL NULL literal")
+ void testNullHandling() {
+ assertNull(codec.encode(null, VERSION));
+ assertEquals("NULL", codec.format(null));
+ }
+
+ @Test
+ @DisplayName("Formatting quotes the character as CQL text, so an embedded quote is escaped rather than breaking the statement")
+ void testFormatEscapesQuotes() {
+ assertEquals(TypeCodecs.TEXT.format("'"), codec.format('\''));
+ }
+
+ @Test
+ @DisplayName("An empty text column decodes to null, since there is no character to return")
+ void testEmptyStringDecodesToNull() {
+ final ByteBuffer empty = TypeCodecs.TEXT.encode("", VERSION);
+
+ assertNull(codec.decode(empty, VERSION));
+ }
+
+ @Test
+ @DisplayName("A null text column decodes to null")
+ void testNullBytesDecodeToNull() {
+ assertNull(codec.decode(null, VERSION));
+ }
+
+ @Test
+ @DisplayName("A multi-character column value decodes to its first character rather than throwing")
+ void testMultiCharacterValueDecodesToFirstCharacter() {
+ // Nothing stops a text column holding more than one character, so the codec has to answer something.
+ // Taking the first character is lossy, and pinning it here is what makes that a decision rather than
+ // an accident - if it should instead throw, this test is the place that argues about it.
+ final ByteBuffer several = TypeCodecs.TEXT.encode("abc", VERSION);
+
+ assertEquals('a', codec.decode(several, VERSION));
+ }
+
+ @Test
+ @DisplayName("Parsing follows the same rules as decoding")
+ void testParse() {
+ assertEquals('a', codec.parse("'a'"));
+ assertNull(codec.parse("''"));
+ assertNull(codec.parse("NULL"));
+ assertNull(codec.parse(null));
+ }
+
+ @Test
+ @DisplayName("The codec binds the CQL text type")
+ void testCqlTypeIsText() {
+ assertEquals(TypeCodecs.TEXT.getCqlType(), codec.getCqlType());
+ }
+
+ @Test
+ @DisplayName("The declared Java type is Character, which is what makes the registry select it for a CHAR field")
+ void testDeclaredJavaTypeIsCharacter() {
+ assertEquals(Character.class, codec.getJavaType().getRawType());
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/FlexibleCodecTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/FlexibleCodecTest.java
new file mode 100644
index 000000000000..900fe639b9bf
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/FlexibleCodecTest.java
@@ -0,0 +1,293 @@
+/*
+ * 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 org.apache.nifi.service.cassandra.mapping;
+
+import com.datastax.oss.driver.api.core.ProtocolVersion;
+import com.datastax.oss.driver.api.core.type.DataType;
+import com.datastax.oss.driver.api.core.type.DataTypes;
+import com.datastax.oss.driver.api.core.type.codec.TypeCodec;
+import com.datastax.oss.driver.api.core.type.codec.TypeCodecs;
+import org.apache.nifi.serialization.record.util.IllegalTypeConversionException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.nio.ByteBuffer;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/**
+ * Covers the {@code Flexible*Codec} family's reason for existing.
+ *
+ *
Each of these codecs wraps a built-in driver codec that binds one exact Java type, and widens it to accept
+ * anything {@code DataTypeUtils} considers compatible - a numeric String where an int is expected, a Long where
+ * a short is expected, and so on. The integration suite writes one value of one Java type per CQL column, so it
+ * only ever walks the canonical path; the widening is what these tests cover.
+ *
+ *
The assertion throughout is that the flexible codec produces byte-for-byte the same encoding as
+ * the built-in codec given the canonical value. That is the property that matters: a record field arriving as
+ * a String must land in the column indistinguishably from one arriving as an Integer.
+ */
+class FlexibleCodecTest {
+
+ private static final ProtocolVersion VERSION = ProtocolVersion.DEFAULT;
+
+ // ---------------------------------------------------------------------------------------------------
+ // The contract every codec in the family shares
+ // ---------------------------------------------------------------------------------------------------
+
+ static Stream allCodecs() {
+ return Stream.of(
+ arguments("FlexibleIntCodec", new FlexibleIntCodec()),
+ arguments("FlexibleBigIntCodec", new FlexibleBigIntCodec()),
+ arguments("FlexibleSmallIntCodec", new FlexibleSmallIntCodec()),
+ arguments("FlexibleTinyIntCodec", new FlexibleTinyIntCodec()),
+ arguments("FlexibleDoubleCodec", new FlexibleDoubleCodec()),
+ arguments("FlexibleFloatCodec", new FlexibleFloatCodec()),
+ arguments("FlexibleBooleanCodec", new FlexibleBooleanCodec()),
+ arguments("FlexibleDateCodec", new FlexibleDateCodec()),
+ arguments("FlexibleTimeCodec", new FlexibleTimeCodec()),
+ arguments("FlexibleCounterCodec", new FlexibleCounterCodec()));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("allCodecs")
+ @DisplayName("A null value encodes to null, so a record field that is absent writes nothing rather than failing")
+ void testEncodeNullReturnsNull(final String name, final TypeCodec> codec) {
+ assertNull(encode(codec, null));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("allCodecs")
+ @DisplayName("A null value formats as the CQL NULL literal")
+ void testFormatNullReturnsNullLiteral(final String name, final TypeCodec> codec) {
+ assertEquals("NULL", format(codec, null));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("allCodecs")
+ @DisplayName("The Java type is declared as Object, so the registry treats the codec as a fallback rather than rejecting it as a duplicate of the built-in")
+ void testJavaTypeIsWideEnoughToBeAFallback(final String name, final TypeCodec> codec) {
+ // Registering a codec that claims the same exact Java type as a built-in gets it silently dropped.
+ // Declaring a supertype is what makes the registry reach these only after the built-in's exact match
+ // fails, so this is load-bearing rather than incidental - see each codec's class javadoc.
+ final Class> rawJavaType = codec.getJavaType().getRawType();
+ assertEquals(true, rawJavaType == Object.class || rawJavaType == Number.class,
+ () -> name + " declares " + rawJavaType.getName() + ", which the built-in codec already claims");
+ }
+
+ static Stream codecCqlTypes() {
+ return Stream.of(
+ arguments("FlexibleIntCodec", new FlexibleIntCodec(), DataTypes.INT),
+ arguments("FlexibleBigIntCodec", new FlexibleBigIntCodec(), DataTypes.BIGINT),
+ arguments("FlexibleSmallIntCodec", new FlexibleSmallIntCodec(), DataTypes.SMALLINT),
+ arguments("FlexibleTinyIntCodec", new FlexibleTinyIntCodec(), DataTypes.TINYINT),
+ arguments("FlexibleDoubleCodec", new FlexibleDoubleCodec(), DataTypes.DOUBLE),
+ arguments("FlexibleFloatCodec", new FlexibleFloatCodec(), DataTypes.FLOAT),
+ arguments("FlexibleBooleanCodec", new FlexibleBooleanCodec(), DataTypes.BOOLEAN),
+ arguments("FlexibleDateCodec", new FlexibleDateCodec(), DataTypes.DATE),
+ arguments("FlexibleTimeCodec", new FlexibleTimeCodec(), DataTypes.TIME),
+ arguments("FlexibleCounterCodec", new FlexibleCounterCodec(), DataTypes.COUNTER));
+ }
+
+ @ParameterizedTest(name = "{0} -> {2}")
+ @MethodSource("codecCqlTypes")
+ @DisplayName("Each codec binds the CQL type it is named for, which is the key the registry selects it by")
+ void testCqlType(final String name, final TypeCodec> codec, final DataType expected) {
+ assertEquals(expected, codec.getCqlType());
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("allCodecs")
+ @DisplayName("parse() of the CQL NULL literal yields null rather than a zero value")
+ void testParseNullLiteral(final String name, final TypeCodec> codec) {
+ // parse() delegates straight to the wrapped codec with no conversion of its own. Asserting it here
+ // is what would catch the delegate being swapped for one that reads a different CQL type.
+ assertNull(codec.parse("NULL"));
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // The widening contract: an alternative Java type encodes exactly like the canonical one
+ // ---------------------------------------------------------------------------------------------------
+
+ static Stream coercions() {
+ final ByteBuffer intBytes = TypeCodecs.INT.encode(42, VERSION);
+ final ByteBuffer bigIntBytes = TypeCodecs.BIGINT.encode(42L, VERSION);
+ final ByteBuffer smallIntBytes = TypeCodecs.SMALLINT.encode((short) 42, VERSION);
+ final ByteBuffer wideIntBytes = TypeCodecs.INT.encode(70_000, VERSION);
+ final ByteBuffer wideBigIntBytes = TypeCodecs.BIGINT.encode(5_000_000_000L, VERSION);
+ final ByteBuffer wideSmallIntBytes = TypeCodecs.SMALLINT.encode((short) 300, VERSION);
+ final ByteBuffer tinyIntBytes = TypeCodecs.TINYINT.encode((byte) 42, VERSION);
+ final ByteBuffer doubleBytes = TypeCodecs.DOUBLE.encode(42.5d, VERSION);
+ final ByteBuffer floatBytes = TypeCodecs.FLOAT.encode(42.5f, VERSION);
+ final ByteBuffer trueBytes = TypeCodecs.BOOLEAN.encode(Boolean.TRUE, VERSION);
+ final ByteBuffer falseBytes = TypeCodecs.BOOLEAN.encode(Boolean.FALSE, VERSION);
+ final ByteBuffer dateBytes = TypeCodecs.DATE.encode(LocalDate.of(2026, 8, 10), VERSION);
+ final ByteBuffer timeBytes = TypeCodecs.TIME.encode(LocalTime.of(13, 45, 30), VERSION);
+ final ByteBuffer counterBytes = TypeCodecs.BIGINT.encode(42L, VERSION);
+
+ return Stream.of(
+ // int - the canonical Integer, plus every other numeric width and a numeric String.
+ // The wide cases matter more than they look: a value inside every numeric range cannot tell
+ // an int conversion apart from a short or byte one, so each width also gets a value that only
+ // fits the column it is bound to.
+ arguments("int <- Integer", new FlexibleIntCodec(), 42, intBytes),
+ arguments("int <- Long", new FlexibleIntCodec(), 42L, intBytes),
+ arguments("int <- Short", new FlexibleIntCodec(), (short) 42, intBytes),
+ arguments("int <- Byte", new FlexibleIntCodec(), (byte) 42, intBytes),
+ arguments("int <- Double", new FlexibleIntCodec(), 42.0d, intBytes),
+ arguments("int <- String", new FlexibleIntCodec(), "42", intBytes),
+ arguments("int <- Integer beyond short range", new FlexibleIntCodec(), 70_000, wideIntBytes),
+ arguments("int <- Long beyond short range", new FlexibleIntCodec(), 70_000L, wideIntBytes),
+ arguments("int <- String beyond short range", new FlexibleIntCodec(), "70000", wideIntBytes),
+
+ // bigint
+ arguments("bigint <- Long", new FlexibleBigIntCodec(), 42L, bigIntBytes),
+ arguments("bigint <- Integer", new FlexibleBigIntCodec(), 42, bigIntBytes),
+ arguments("bigint <- Short", new FlexibleBigIntCodec(), (short) 42, bigIntBytes),
+ arguments("bigint <- String", new FlexibleBigIntCodec(), "42", bigIntBytes),
+ arguments("bigint <- Long beyond int range", new FlexibleBigIntCodec(), 5_000_000_000L, wideBigIntBytes),
+ arguments("bigint <- String beyond int range", new FlexibleBigIntCodec(), "5000000000", wideBigIntBytes),
+
+ // smallint
+ arguments("smallint <- Short", new FlexibleSmallIntCodec(), (short) 42, smallIntBytes),
+ arguments("smallint <- Integer", new FlexibleSmallIntCodec(), 42, smallIntBytes),
+ arguments("smallint <- Long", new FlexibleSmallIntCodec(), 42L, smallIntBytes),
+ arguments("smallint <- String", new FlexibleSmallIntCodec(), "42", smallIntBytes),
+ arguments("smallint <- Short beyond byte range", new FlexibleSmallIntCodec(), (short) 300, wideSmallIntBytes),
+ arguments("smallint <- String beyond byte range", new FlexibleSmallIntCodec(), "300", wideSmallIntBytes),
+
+ // tinyint
+ arguments("tinyint <- Byte", new FlexibleTinyIntCodec(), (byte) 42, tinyIntBytes),
+ arguments("tinyint <- Integer", new FlexibleTinyIntCodec(), 42, tinyIntBytes),
+ arguments("tinyint <- String", new FlexibleTinyIntCodec(), "42", tinyIntBytes),
+
+ // double
+ arguments("double <- Double", new FlexibleDoubleCodec(), 42.5d, doubleBytes),
+ arguments("double <- Float", new FlexibleDoubleCodec(), 42.5f, doubleBytes),
+ arguments("double <- String", new FlexibleDoubleCodec(), "42.5", doubleBytes),
+ arguments("double <- Integer", new FlexibleDoubleCodec(), 42,
+ TypeCodecs.DOUBLE.encode(42.0d, VERSION)),
+
+ // float
+ arguments("float <- Float", new FlexibleFloatCodec(), 42.5f, floatBytes),
+ arguments("float <- Double", new FlexibleFloatCodec(), 42.5d, floatBytes),
+ arguments("float <- String", new FlexibleFloatCodec(), "42.5", floatBytes),
+
+ // boolean - the String forms are the point, and they are matched case-insensitively
+ arguments("boolean <- Boolean.TRUE", new FlexibleBooleanCodec(), Boolean.TRUE, trueBytes),
+ arguments("boolean <- \"true\"", new FlexibleBooleanCodec(), "true", trueBytes),
+ arguments("boolean <- \"TRUE\"", new FlexibleBooleanCodec(), "TRUE", trueBytes),
+ arguments("boolean <- \"True\"", new FlexibleBooleanCodec(), "True", trueBytes),
+ arguments("boolean <- Boolean.FALSE", new FlexibleBooleanCodec(), Boolean.FALSE, falseBytes),
+ arguments("boolean <- \"false\"", new FlexibleBooleanCodec(), "false", falseBytes),
+
+ // date - accepted via DataTypeUtils' DATE conversion, which takes several shapes
+ arguments("date <- LocalDate", new FlexibleDateCodec(), LocalDate.of(2026, 8, 10), dateBytes),
+ arguments("date <- java.sql.Date", new FlexibleDateCodec(),
+ java.sql.Date.valueOf(LocalDate.of(2026, 8, 10)), dateBytes),
+ arguments("date <- String yyyy-MM-dd", new FlexibleDateCodec(), "2026-08-10", dateBytes),
+
+ // time
+ arguments("time <- java.sql.Time", new FlexibleTimeCodec(),
+ java.sql.Time.valueOf(LocalTime.of(13, 45, 30)), timeBytes),
+ arguments("time <- String HH:mm:ss", new FlexibleTimeCodec(), "13:45:30", timeBytes),
+
+ // counter - any Number narrows to the long the counter column holds
+ arguments("counter <- Long", new FlexibleCounterCodec(), 42L, counterBytes),
+ arguments("counter <- Integer", new FlexibleCounterCodec(), 42, counterBytes),
+ arguments("counter <- Short", new FlexibleCounterCodec(), (short) 42, counterBytes));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("coercions")
+ @DisplayName("An accepted alternative Java type encodes byte-for-byte like the canonical type")
+ void testAlternativeTypeEncodesLikeCanonicalType(final String name, final TypeCodec> codec,
+ final Object input, final ByteBuffer expected) {
+ assertEquals(expected, encode(codec, input));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("coercions")
+ @DisplayName("format() applies the same conversion as encode(), so a value logged in a statement matches the value bound")
+ void testFormatAppliesTheSameConversionAsEncode(final String name, final TypeCodec> codec,
+ final Object input, final ByteBuffer expected) {
+ // format() and encode() each convert independently in these codecs. Asserting they agree is what
+ // catches one of them being changed alone.
+ assertEquals(format(codec, canonicalValueOf(codec, input)), format(codec, input));
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // Values the widening does not extend to
+ // ---------------------------------------------------------------------------------------------------
+
+ static Stream rejections() {
+ return Stream.of(
+ arguments("int <- arbitrary Object", new FlexibleIntCodec(), new Object()),
+ arguments("bigint <- arbitrary Object", new FlexibleBigIntCodec(), new Object()),
+ arguments("double <- arbitrary Object", new FlexibleDoubleCodec(), new Object()),
+ arguments("float <- arbitrary Object", new FlexibleFloatCodec(), new Object()),
+ arguments("smallint <- arbitrary Object", new FlexibleSmallIntCodec(), new Object()),
+ arguments("boolean <- arbitrary Object", new FlexibleBooleanCodec(), new Object()),
+ arguments("boolean <- a String that is neither true nor false", new FlexibleBooleanCodec(), "yes"),
+ // Widening is not the same as truncating: a value too large for the column is an error, not a wrap.
+ arguments("int <- a Long too large for an int", new FlexibleIntCodec(), Long.MAX_VALUE));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("rejections")
+ @DisplayName("An incompatible value is rejected with an attributable error rather than written as something else")
+ void testIncompatibleValueIsRejected(final String name, final TypeCodec> codec, final Object input) {
+ final IllegalTypeConversionException thrown =
+ assertThrows(IllegalTypeConversionException.class, () -> encode(codec, input));
+
+ // The message names the CQL type being bound, which is what makes the failure attributable to a column
+ // rather than to "something in the write path".
+ assertEquals(true, thrown.getMessage().contains(codec.getCqlType().toString()),
+ () -> "expected the CQL type in: " + thrown.getMessage());
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------------------------------
+
+ @SuppressWarnings("unchecked")
+ private static ByteBuffer encode(final TypeCodec> codec, final Object value) {
+ return ((TypeCodec) codec).encode(value, VERSION);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static String format(final TypeCodec> codec, final Object value) {
+ return ((TypeCodec) codec).format(value);
+ }
+
+ /**
+ * Decodes what {@code input} encodes to, giving the canonical Java value the column actually holds. Used to
+ * check {@code format} against {@code encode} without restating every expected literal a second time.
+ */
+ private static Object canonicalValueOf(final TypeCodec> codec, final Object input) {
+ return codec.decode(encode(codec, input), VERSION);
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/JavaSqlBridgeCodecTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/JavaSqlBridgeCodecTest.java
new file mode 100644
index 000000000000..0e47e9d55ae8
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mapping/JavaSqlBridgeCodecTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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 org.apache.nifi.service.cassandra.mapping;
+
+import com.datastax.oss.driver.api.core.ProtocolVersion;
+import com.datastax.oss.driver.api.core.type.codec.TypeCodec;
+import com.datastax.oss.driver.api.core.type.codec.TypeCodecs;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.nio.ByteBuffer;
+import java.sql.Date;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/**
+ * Covers the three {@code java.sql.*} bridge codecs.
+ *
+ * These differ from the {@code Flexible*} family in that they convert in both directions: a record field
+ * carrying a {@code java.sql} temporal type binds to the CQL column, and a value read back from the column
+ * comes out as that same {@code java.sql} type. Round-tripping is therefore the property to assert, and it is
+ * the one the integration suite cannot check - the ITs read back through the record layer, which does its own
+ * conversion, so a bug in the decode direction here would be masked.
+ */
+class JavaSqlBridgeCodecTest {
+
+ private static final ProtocolVersion VERSION = ProtocolVersion.DEFAULT;
+
+ private static final LocalDate A_DATE = LocalDate.of(2026, 8, 10);
+ private static final LocalTime A_TIME = LocalTime.of(13, 45, 30);
+ private static final Instant AN_INSTANT = Instant.parse("2026-08-10T13:45:30Z");
+
+ static Stream codecs() {
+ return Stream.of(
+ arguments("JavaSQLDateCodec", new JavaSQLDateCodec()),
+ arguments("JavaSQLTimeCodec", new JavaSQLTimeCodec()),
+ arguments("JavaSQLTimestampCodec", new JavaSQLTimestampCodec()));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("codecs")
+ @DisplayName("A null value encodes to null and formats as the CQL NULL literal")
+ void testNullHandling(final String name, final TypeCodec> codec) {
+ assertNull(encode(codec, null));
+ assertEquals("NULL", format(codec, null));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("codecs")
+ @DisplayName("Decoding null bytes yields null rather than a zero-epoch value")
+ void testDecodeNullBytesReturnsNull(final String name, final TypeCodec> codec) {
+ // The distinction matters: a NULL column must not read back as 1970-01-01.
+ assertNull(codec.decode(null, VERSION));
+ }
+
+ @Test
+ @DisplayName("A java.sql.Timestamp round-trips through the CQL timestamp column unchanged")
+ void testTimestampRoundTrip() {
+ final JavaSQLTimestampCodec codec = new JavaSQLTimestampCodec();
+ final Timestamp original = Timestamp.from(AN_INSTANT);
+
+ final ByteBuffer encoded = codec.encode(original, VERSION);
+
+ assertEquals(TypeCodecs.TIMESTAMP.encode(AN_INSTANT, VERSION), encoded,
+ "should bind exactly as the driver's own Instant codec would");
+ assertEquals(original, codec.decode(encoded, VERSION));
+ }
+
+ @Test
+ @DisplayName("A java.sql.Date round-trips through the CQL date column as the same calendar day")
+ void testDateRoundTrip() {
+ final JavaSQLDateCodec codec = new JavaSQLDateCodec();
+ final Date original = Date.valueOf(A_DATE);
+
+ final ByteBuffer encoded = codec.encode(original, VERSION);
+
+ assertEquals(TypeCodecs.DATE.encode(A_DATE, VERSION), encoded,
+ "should bind exactly as the driver's own LocalDate codec would");
+ // Comparing the calendar day rather than the Date instances: both conversions go through the system
+ // default zone, so asserting on toLocalDate is what actually states the intended property.
+ assertEquals(A_DATE, codec.decode(encoded, VERSION).toLocalDate());
+ }
+
+ @Test
+ @DisplayName("A java.sql.Time round-trips through the CQL time column as the same wall-clock time")
+ void testTimeRoundTrip() {
+ final JavaSQLTimeCodec codec = new JavaSQLTimeCodec();
+ final Time original = Time.valueOf(A_TIME);
+
+ final ByteBuffer encoded = codec.encode(original, VERSION);
+
+ assertEquals(TypeCodecs.TIME.encode(A_TIME, VERSION), encoded,
+ "should bind exactly as the driver's own LocalTime codec would");
+ assertEquals(A_TIME, codec.decode(encoded, VERSION).toLocalTime());
+ }
+
+ @Test
+ @DisplayName("Timestamp keeps millisecond precision, which is all a CQL timestamp column stores")
+ void testTimestampMillisecondPrecision() {
+ final JavaSQLTimestampCodec codec = new JavaSQLTimestampCodec();
+ final Timestamp original = Timestamp.from(Instant.parse("2026-08-10T13:45:30.123Z"));
+
+ final Timestamp roundTripped = codec.decode(codec.encode(original, VERSION), VERSION);
+
+ assertEquals(original, roundTripped);
+ assertEquals(123_000_000, roundTripped.getNanos(), "milliseconds survive; the column holds no finer unit");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("codecs")
+ @DisplayName("parse() reverses format(), so a value written into CQL text can be read back out of it")
+ void testParseReversesFormat(final String name, final TypeCodec> codec) {
+ // format/parse is the string-literal path the driver uses for statement logging and for simple
+ // statements, and it converts independently of encode/decode - so it needs asserting separately.
+ final Object original = codec.decode(encode(codec, canonicalValueFor(name)), VERSION);
+
+ assertEquals(original, codec.parse(format(codec, original)));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("codecs")
+ @DisplayName("parse() of the CQL NULL literal yields null rather than a zero-epoch value")
+ void testParseNullLiteralReturnsNull(final String name, final TypeCodec> codec) {
+ assertNull(codec.parse("NULL"));
+ assertNull(codec.parse(null));
+ }
+
+ private static Object canonicalValueFor(final String codecName) {
+ return switch (codecName) {
+ case "JavaSQLDateCodec" -> Date.valueOf(A_DATE);
+ case "JavaSQLTimeCodec" -> Time.valueOf(A_TIME);
+ case "JavaSQLTimestampCodec" -> Timestamp.from(AN_INSTANT);
+ default -> throw new IllegalArgumentException(codecName);
+ };
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("codecs")
+ @DisplayName("The declared Java type is the java.sql type, which is what makes the registry select these for it")
+ void testDeclaredJavaTypeIsTheSqlType(final String name, final TypeCodec> codec) {
+ final Class> rawType = codec.getJavaType().getRawType();
+ assertEquals("java.sql", rawType.getPackageName(),
+ () -> name + " declares " + rawType.getName());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ByteBuffer encode(final TypeCodec> codec, final Object value) {
+ return ((TypeCodec) codec).encode(value, VERSION);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static String format(final TypeCodec> codec, final Object value) {
+ return ((TypeCodec) codec).format(value);
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mock/MockCassandraProcessor.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mock/MockCassandraProcessor.java
new file mode 100644
index 000000000000..1e2ac303bc2f
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/java/org/apache/nifi/service/cassandra/mock/MockCassandraProcessor.java
@@ -0,0 +1,51 @@
+/*
+ * 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 org.apache.nifi.service.cassandra.mock;
+
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.service.cassandra.CassandraCQLExecutionService;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Mock Cassandra processor for testing CassandraSessionProvider
+ */
+public class MockCassandraProcessor extends AbstractProcessor {
+ private static final PropertyDescriptor CASSANDRA_SESSION_PROVIDER = new PropertyDescriptor.Builder()
+ .name("Cassandra Session Provider")
+ .required(true)
+ .description("Controller Service to obtain a Cassandra connection session")
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .identifiesControllerService(CassandraCQLExecutionService.class)
+ .build();
+
+ @Override
+ public List getSupportedPropertyDescriptors() {
+ return Collections.singletonList(CASSANDRA_SESSION_PROVIDER);
+ }
+
+ @Override
+ public void onTrigger(ProcessContext context, ProcessSession session) throws ProcessException {
+
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/resources/cassandra-base-config/cassandra.yaml b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/resources/cassandra-base-config/cassandra.yaml
new file mode 100644
index 000000000000..28048e8a66ff
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/resources/cassandra-base-config/cassandra.yaml
@@ -0,0 +1,271 @@
+# 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.
+
+# Stock cassandra:5.0 cassandra.yaml (trimmed of its explanatory comments). Read at test runtime by
+# CassandraConfigOverrides, which patches in the one or two lines a given IT scenario needs and copies the
+# result directly into the container, rather than checking in a full near-duplicate copy of this file per
+# scenario. The only deviation from stock: the stock keystore_password/key_password values under the
+# disabled transparent_data_encryption_options block are dropped so no credential literal is committed -
+# that block is never activated by any scenario.
+cluster_name: 'Test Cluster'
+
+num_tokens: 16
+
+allocate_tokens_for_local_replication_factor: 3
+
+hinted_handoff_enabled: true
+
+max_hint_window: 3h
+
+hinted_handoff_throttle: 1024KiB
+
+max_hints_delivery_threads: 2
+
+hints_flush_period: 10000ms
+
+max_hints_file_size: 128MiB
+
+auto_hints_cleanup_enabled: false
+
+batchlog_replay_throttle: 1024KiB
+
+authenticator: AllowAllAuthenticator
+
+authorizer: AllowAllAuthorizer
+
+role_manager: CassandraRoleManager
+
+network_authorizer: AllowAllNetworkAuthorizer
+
+cidr_authorizer:
+ class_name: AllowAllCIDRAuthorizer
+
+roles_validity: 2000ms
+
+permissions_validity: 2000ms
+
+credentials_validity: 2000ms
+
+partitioner: org.apache.cassandra.dht.Murmur3Partitioner
+
+cdc_enabled: false
+
+disk_failure_policy: stop
+
+commit_failure_policy: stop
+
+prepared_statements_cache_size:
+
+key_cache_size:
+
+key_cache_save_period: 4h
+
+row_cache_size: 0MiB
+
+row_cache_save_period: 0s
+
+counter_cache_size:
+
+counter_cache_save_period: 7200s
+
+commitlog_sync: periodic
+
+commitlog_sync_period: 10000ms
+
+commitlog_segment_size: 32MiB
+
+commitlog_disk_access_mode: legacy
+
+seed_provider:
+
+ - class_name: org.apache.cassandra.locator.SimpleSeedProvider
+ parameters:
+
+ - seeds: "127.0.0.1:7000"
+
+concurrent_reads: 32
+concurrent_writes: 32
+concurrent_counter_writes: 32
+
+concurrent_materialized_view_writes: 32
+
+memtable:
+ configurations:
+ skiplist:
+ class_name: SkipListMemtable
+ trie:
+ class_name: TrieMemtable
+ default:
+ inherits: skiplist
+
+memtable_allocation_type: heap_buffers
+
+index_summary_capacity:
+
+index_summary_resize_interval: 60m
+
+trickle_fsync: false
+
+trickle_fsync_interval: 10240KiB
+
+storage_port: 7000
+
+ssl_storage_port: 7001
+
+listen_address: localhost
+
+# Must stay present (commented is fine): the official cassandra image's docker-entrypoint.sh templates
+# these via sed on startup so Testcontainers can inject the container's reachable address. Deleting them
+# breaks that substitution and Cassandra refuses to start with rpc_address bound to a wildcard.
+# broadcast_address: 1.2.3.4
+# broadcast_rpc_address: 1.2.3.4
+
+start_native_transport: true
+
+native_transport_port: 9042
+
+native_transport_allow_older_protocols: true
+
+rpc_address: localhost
+
+rpc_keepalive: true
+
+incremental_backups: false
+
+snapshot_before_compaction: false
+
+auto_snapshot: true
+
+snapshot_links_per_second: 0
+
+column_index_cache_size: 2KiB
+
+concurrent_materialized_view_builders: 1
+
+compaction_throughput: 64MiB/s
+
+sstable_preemptive_open_interval: 50MiB
+
+uuid_sstable_identifiers_enabled: false
+
+read_request_timeout: 5000ms
+
+range_request_timeout: 10000ms
+
+write_request_timeout: 2000ms
+
+counter_write_request_timeout: 5000ms
+
+cas_contention_timeout: 1000ms
+
+truncate_request_timeout: 60000ms
+
+request_timeout: 10000ms
+
+slow_query_log_timeout: 500ms
+
+endpoint_snitch: SimpleSnitch
+
+dynamic_snitch_update_interval: 100ms
+
+dynamic_snitch_reset_interval: 600000ms
+
+dynamic_snitch_badness_threshold: 1.0
+
+crypto_provider:
+ - class_name: org.apache.cassandra.security.DefaultCryptoProvider
+ parameters:
+ - fail_on_missing_provider: "false"
+
+server_encryption_options:
+
+ internode_encryption: none
+
+ legacy_ssl_storage_port_enabled: false
+
+ keystore: conf/.keystore
+
+ require_client_auth: false
+
+ truststore: conf/.truststore
+
+ require_endpoint_verification: false
+
+client_encryption_options:
+
+ enabled: false
+
+ keystore: conf/.keystore
+
+ require_client_auth: false
+
+internode_compression: dc
+
+inter_dc_tcp_nodelay: false
+
+trace_type_query_ttl: 1d
+
+trace_type_repair_ttl: 7d
+
+user_defined_functions_enabled: false
+
+transparent_data_encryption_options:
+ enabled: false
+ chunk_length_kb: 64
+ cipher: AES/CBC/PKCS5Padding
+ key_alias: testing:1
+
+ key_provider:
+ - class_name: org.apache.cassandra.security.JKSKeyProvider
+ parameters:
+ - keystore: conf/.keystore
+ store_type: JCEKS
+
+tombstone_warn_threshold: 1000
+tombstone_failure_threshold: 100000
+
+replica_filtering_protection:
+
+ cached_rows_warn_threshold: 2000
+ cached_rows_fail_threshold: 32000
+
+batch_size_warn_threshold: 5KiB
+
+batch_size_fail_threshold: 50KiB
+
+unlogged_batch_across_partitions_warn_threshold: 10
+
+audit_logging_options:
+ enabled: false
+ logger:
+ - class_name: BinAuditLogger
+
+diagnostic_events_enabled: false
+
+repaired_data_tracking_for_range_reads_enabled: false
+repaired_data_tracking_for_partition_reads_enabled: false
+
+report_unconfirmed_repaired_data_mismatches: false
+
+materialized_views_enabled: false
+
+sasi_indexes_enabled: false
+
+transient_replication_enabled: false
+
+drop_compact_storage_enabled: false
+
+storage_compatibility_mode: CASSANDRA_4
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/resources/init.cql b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/resources/init.cql
new file mode 100644
index 000000000000..a5b7be221fa2
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/src/test/resources/init.cql
@@ -0,0 +1,47 @@
+-- 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.
+
+create keyspace testspace with replication = { 'class': 'SimpleStrategy', 'replication_factor': 1};
+
+create table testspace.message
+(
+ sender text,
+ receiver text,
+ message text,
+ when_sent timestamp,
+ primary key ( sender, receiver, when_sent )
+);
+
+create table testspace.query_test
+(
+ column_a text,
+ column_b text,
+ when timestamp,
+ primary key ( (column_a), column_b)
+);
+
+create table testspace.counter_test
+(
+ column_a text,
+ increment_field counter,
+ primary key ( column_a )
+);
+
+create table testspace.simple_set_test
+(
+ username text,
+ is_active boolean,
+ primary key ( username )
+);
+
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/pom.xml b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/pom.xml
new file mode 100644
index 000000000000..17ccac4acef5
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/pom.xml
@@ -0,0 +1,100 @@
+
+
+
+
+ nifi-cql-bundle
+ org.apache.nifi
+ 2.12.0-SNAPSHOT
+
+ 4.0.0
+
+ nifi-cql-it-common
+ jar
+
+
+ 4.19.3
+
+
+
+
+
+ org.apache.nifi
+ nifi-api
+
+
+ org.apache.nifi
+ nifi-cql-services-api
+ 2.12.0-SNAPSHOT
+
+
+ org.apache.nifi
+ nifi-mock
+
+
+ org.apache.nifi
+ nifi-record
+
+
+ org.apache.cassandra
+ java-driver-core
+ ${driver.version}
+
+
+ org.apache.nifi
+ nifi-security-cert-builder
+ 2.12.0-SNAPSHOT
+
+
+ org.apache.nifi
+ nifi-ssl-context-service
+ 2.12.0-SNAPSHOT
+
+
+
+ org.testcontainers
+ testcontainers
+ ${testcontainers.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ test-jar
+
+ test-jar
+
+
+
+
+
+
+
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlCrudIT.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlCrudIT.java
new file mode 100644
index 000000000000..7c9a115ccdaf
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlCrudIT.java
@@ -0,0 +1,595 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import com.datastax.oss.driver.api.core.CqlSession;
+import com.datastax.oss.driver.api.core.cql.ResultSet;
+import com.datastax.oss.driver.api.core.cql.Row;
+import org.apache.nifi.serialization.SimpleRecordSchema;
+import org.apache.nifi.serialization.record.MapRecord;
+import org.apache.nifi.serialization.record.RecordField;
+import org.apache.nifi.serialization.record.RecordFieldType;
+import org.apache.nifi.serialization.record.RecordSchema;
+import org.apache.nifi.service.cql.api.constants.CqlBatchType;
+import org.apache.nifi.service.cql.api.constants.UpdateMethod;
+import org.apache.nifi.service.cql.api.exception.QueryFailureException;
+import org.apache.nifi.service.cql.api.lookup.CqlRow;
+import org.apache.nifi.service.cql.api.lookup.CqlStatementResult;
+import org.apache.nifi.service.cql.api.metadata.QualifiedTableName;
+import org.apache.nifi.service.cql.api.service.AbstractCQLExecutionService;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.service.cql.api.service.QueryOverrides;
+import org.apache.nifi.service.cql.api.service.WriteOverrides;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Exercises the CRUD-oriented public methods of {@link CQLExecutionService} - {@code insert}, {@code update},
+ * {@code query}, and {@code execute} - against whichever backend the concrete subclass wires up. Assumes
+ * the subclass has already bootstrapped a {@code testspace} keyspace containing the {@code message},
+ * {@code counter_test}, {@code simple_set_test}, and {@code query_test} tables (see {@code init.cql} in the
+ * Cassandra module for the canonical schema) and has called {@link #initializeSessionProvider(CqlConnectionInfo)}.
+ *
+ * Every test here turns on a server- or driver-observable effect a unit test can only assume: a counter's
+ * running total, a conditional write handing back the row that beat it, an explicit {@code WRITETIME} or
+ * {@code TTL} taking hold, a LOGGED batch refusing a counter mutation, an unsatisfiable consistency level
+ * failing on the first {@code execute()}. The driver-metadata-to-{@code PrimaryKey} mapping that backs
+ * {@code getMetadata} is covered without a cluster in {@code CassandraTableMetadataMappingTest}.
+ *
+ * Setup is a plain protected method rather than a JUnit lifecycle callback (e.g. {@code @BeforeAll})
+ * deliberately: on a {@code @ParameterizedClass} leaf, {@code @BeforeAll} - even inherited from this
+ * superclass - runs BEFORE the leaf's own {@code @BeforeParameterizedClassInvocation} method, so an
+ * inherited {@code @BeforeAll} here would run before the container (and connection info) it depends on
+ * exists. Calling this method explicitly from the leaf's {@code @BeforeParameterizedClassInvocation} avoids
+ * that ordering trap entirely.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public abstract class AbstractCqlCrudIT {
+
+ protected CQLExecutionService sessionProvider;
+
+ private CqlSession session;
+
+ private CqlConnectionInfo connectionInfo;
+
+ private String kvTableName;
+
+ /**
+ * @return a fresh, unconfigured instance of the backend implementation under test
+ */
+ protected abstract CQLExecutionService newSessionProvider();
+
+ protected void initializeSessionProvider(final CqlConnectionInfo connectionInfo) throws Exception {
+ this.connectionInfo = connectionInfo;
+ this.session = connectionInfo.session();
+
+ this.kvTableName = connectionInfo.keyspace() + ".execute_kv";
+ CqlDdl.executeWithRetry(session, "create table if not exists " + kvTableName + " (k blob primary key, v blob)");
+
+ this.sessionProvider = CqlServiceRunner.forService(newSessionProvider())
+ .withConnection(connectionInfo)
+ .enable();
+ }
+
+ private RecordSchema getMessageSchema() {
+ List fields = List.of(
+ new RecordField("sender", RecordFieldType.STRING.getDataType()),
+ new RecordField("receiver", RecordFieldType.STRING.getDataType()),
+ new RecordField("message", RecordFieldType.STRING.getDataType()),
+ new RecordField("when_sent", RecordFieldType.TIMESTAMP.getDataType())
+ );
+ return new SimpleRecordSchema(fields);
+ }
+
+ @Test
+ @DisplayName("Inserting a record writes it to the table using the CQL execution service")
+ void testInsertRecord() {
+ RecordSchema schema = getMessageSchema();
+ Map rawRecord = new HashMap<>();
+ rawRecord.put("sender", "john.smith");
+ rawRecord.put("receiver", "jane.smith");
+ rawRecord.put("message", "hello");
+ rawRecord.put("when_sent", Instant.now());
+
+ MapRecord record = new MapRecord(schema, rawRecord);
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "message"), record, Map.of(), WriteOverrides.NONE));
+ }
+
+ @Test
+ @DisplayName("Incrementing then decrementing a counter column produces the expected running total")
+ void testIncrementAndDecrement() throws Exception {
+ RecordField field1 = new RecordField("column_a", RecordFieldType.STRING.getDataType());
+ RecordField field2 = new RecordField("increment_field", RecordFieldType.INT.getDataType());
+ RecordSchema schema = new SimpleRecordSchema(List.of(field1, field2));
+
+ Map map = new HashMap<>();
+ map.put("column_a", "abcdef");
+ map.put("increment_field", 1);
+
+ MapRecord record = new MapRecord(schema, map);
+
+ List updateKeys = new ArrayList<>();
+ updateKeys.add("column_a");
+
+ //Set the initial value
+ sessionProvider.update(new QualifiedTableName(null, "counter_test"), record, Map.of(), updateKeys, UpdateMethod.INCREMENT, WriteOverrides.NONE);
+
+ Thread.sleep(1000);
+
+ sessionProvider.update(new QualifiedTableName(null, "counter_test"), record, Map.of(), updateKeys, UpdateMethod.INCREMENT, WriteOverrides.NONE);
+
+ ResultSet results = session.execute("select increment_field from testspace.counter_test where column_a = 'abcdef'");
+
+ Iterator rowIterator = results.iterator();
+
+ Row row = rowIterator.next();
+
+ assertEquals(2, row.getLong("increment_field"));
+
+ sessionProvider.update(new QualifiedTableName(null, "counter_test"), record, Map.of(), updateKeys, UpdateMethod.DECREMENT, WriteOverrides.NONE);
+
+ results = session.execute("select increment_field from testspace.counter_test where column_a = 'abcdef'");
+
+ rowIterator = results.iterator();
+
+ row = rowIterator.next();
+
+ assertEquals(1, row.getLong("increment_field"));
+ }
+
+ @Test
+ @DisplayName("Batch-inserting records with a LOGGED batch type writes them all")
+ void testBatchInsert() {
+ RecordSchema schema = getMessageSchema();
+ List records = new ArrayList<>();
+ for (int i = 0; i < 3; i++) {
+ Map rawRecord = new HashMap<>();
+ rawRecord.put("sender", "batch.sender." + i);
+ rawRecord.put("receiver", "jane.smith");
+ rawRecord.put("message", "hello " + i);
+ rawRecord.put("when_sent", Instant.now());
+ records.add(new MapRecord(schema, rawRecord));
+ }
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "message"), records, Map.of(), CqlBatchType.LOGGED, WriteOverrides.NONE));
+ }
+
+ @Test
+ @DisplayName("Batch-updating counters requires a COUNTER batch type - Cassandra/ScyllaDB reject counter mutations in a LOGGED batch")
+ void testBatchCounterUpdateRequiresCounterBatchType() throws Exception {
+ RecordField field1 = new RecordField("column_a", RecordFieldType.STRING.getDataType());
+ RecordField field2 = new RecordField("increment_field", RecordFieldType.INT.getDataType());
+ RecordSchema schema = new SimpleRecordSchema(List.of(field1, field2));
+
+ List records = new ArrayList<>();
+ for (String key : List.of("batch-counter-a", "batch-counter-b", "batch-counter-c")) {
+ Map map = new HashMap<>();
+ map.put("column_a", key);
+ map.put("increment_field", 1);
+ records.add(new MapRecord(schema, map));
+ }
+
+ List updateKeys = List.of("column_a");
+
+ // A LOGGED (or UNLOGGED) batch cannot contain counter mutations - the server rejects it outright.
+ // This is exactly the bug PutCQLRecord had: BATCH_STATEMENT_TYPE was validated but never forwarded,
+ // so every batched counter increment/decrement was silently submitted as LOGGED and would have
+ // failed here before the fix.
+ assertThrows(Exception.class, () -> sessionProvider.update(new QualifiedTableName(null, "counter_test"), records, Map.of(), updateKeys,
+ UpdateMethod.INCREMENT, CqlBatchType.LOGGED, WriteOverrides.NONE));
+
+ sessionProvider.update(new QualifiedTableName(null, "counter_test"), records, Map.of(), updateKeys, UpdateMethod.INCREMENT, CqlBatchType.COUNTER, WriteOverrides.NONE);
+
+ for (String key : List.of("batch-counter-a", "batch-counter-b", "batch-counter-c")) {
+ Row row = session.execute("select increment_field from testspace.counter_test where column_a = '" + key + "'").iterator().next();
+ assertEquals(1, row.getLong("increment_field"));
+ }
+ }
+
+ @Test
+ @DisplayName("Inserting a record with a TTL override sets an expiration on the written columns")
+ void testInsertWithTtlOverrideSetsExpiration() {
+ RecordSchema schema = getMessageSchema();
+ Map rawRecord = new HashMap<>();
+ rawRecord.put("sender", "ttl.sender");
+ rawRecord.put("receiver", "ttl.receiver");
+ rawRecord.put("message", "expires soon");
+ rawRecord.put("when_sent", Instant.now());
+
+ MapRecord record = new MapRecord(schema, rawRecord);
+
+ sessionProvider.insert(new QualifiedTableName(null, "message"), record, Map.of(), new WriteOverrides(Duration.ofSeconds(100), null));
+
+ // "when_sent" (the last clustering column) is deliberately omitted: sender + receiver alone
+ // uniquely identify the single row this test wrote, and CQL allows a prefix of clustering
+ // columns in an equality filter without needing ALLOW FILTERING or reconstructing the exact
+ // timestamp literal that was generated for "when_sent".
+ Row row = session.execute(
+ "select TTL(message) as remaining_ttl from testspace.message where sender = 'ttl.sender' and receiver = 'ttl.receiver'")
+ .iterator().next();
+
+ int remainingTtl = row.getInt("remaining_ttl");
+ assertTrue(remainingTtl > 0 && remainingTtl <= 100, "Expected a remaining TTL between 1 and 100 seconds but was " + remainingTtl);
+ }
+
+ @Test
+ @DisplayName("Inserting with a Timestamp Field override uses that record field's value as the CQL write timestamp")
+ void testInsertWithTimestampFieldOverrideUsesRecordValue() {
+ RecordSchema schema = getMessageSchema();
+ Instant eventTime = Instant.now().minus(Duration.ofDays(30)).truncatedTo(ChronoUnit.MILLIS);
+
+ Map rawRecord = new HashMap<>();
+ rawRecord.put("sender", "timestamp.sender");
+ rawRecord.put("receiver", "timestamp.receiver");
+ rawRecord.put("message", "old event");
+ rawRecord.put("when_sent", eventTime);
+
+ MapRecord record = new MapRecord(schema, rawRecord);
+
+ sessionProvider.insert(new QualifiedTableName(null, "message"), record, Map.of(), new WriteOverrides(null, "when_sent"));
+
+ long writeTimeMicros = session.execute(
+ "select WRITETIME(message) as write_time from testspace.message where sender = 'timestamp.sender' and receiver = 'timestamp.receiver'")
+ .iterator().next().getLong("write_time");
+
+ assertEquals(toEpochMicros(eventTime), writeTimeMicros);
+ }
+
+ @Test
+ @DisplayName("Each record in a batch insert carries its own write timestamp when a Timestamp Field override is set")
+ void testBatchInsertWithTimestampFieldOverrideUsesPerRecordValues() {
+ RecordSchema schema = getMessageSchema();
+ Instant olderEventTime = Instant.now().minus(Duration.ofDays(30)).truncatedTo(ChronoUnit.MILLIS);
+ Instant newerEventTime = Instant.now().minus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MILLIS);
+
+ Map olderRawRecord = new HashMap<>();
+ olderRawRecord.put("sender", "batch.timestamp.sender");
+ olderRawRecord.put("receiver", "older.receiver");
+ olderRawRecord.put("message", "older event");
+ olderRawRecord.put("when_sent", olderEventTime);
+
+ Map newerRawRecord = new HashMap<>();
+ newerRawRecord.put("sender", "batch.timestamp.sender");
+ newerRawRecord.put("receiver", "newer.receiver");
+ newerRawRecord.put("message", "newer event");
+ newerRawRecord.put("when_sent", newerEventTime);
+
+ List records = List.of(
+ new MapRecord(schema, olderRawRecord),
+ new MapRecord(schema, newerRawRecord));
+
+ sessionProvider.insert(new QualifiedTableName(null, "message"), records, Map.of(), CqlBatchType.LOGGED, new WriteOverrides(null, "when_sent"));
+
+ assertEquals(toEpochMicros(olderEventTime), writeTimeForBatchTimestampSender("older.receiver"));
+ assertEquals(toEpochMicros(newerEventTime), writeTimeForBatchTimestampSender("newer.receiver"));
+ }
+
+ @Test
+ @DisplayName("Updating with a Timestamp Field override uses that record field's value as the CQL write timestamp")
+ void testUpdateSetWithTimestampFieldOverrideUsesRecordValue() throws Exception {
+ // "when_sent" doubles as both an update key (part of message's primary key, so it identifies which row
+ // to update) and the Timestamp Field source - a realistic pattern, since it's a real column already
+ // being written, not an extra field invented just to carry a timestamp. generateUpdate() only knows
+ // how to treat a record field as either a SET target or a WHERE key, so the Timestamp Field has to be
+ // one of those, not an arbitrary extra field.
+ //
+ // The row-establishing insert below deliberately writes with an explicit, much older timestamp than
+ // "when_sent" itself (which only serves as the row's identity here). Without that, this insert's own
+ // auto-assigned "now" timestamp would land only a fraction of a millisecond away from the override
+ // under test below, and Cassandra's last-write-wins would resolve that near-tie essentially at random -
+ // making the test flaky/misleading regardless of whether the override actually works.
+ Instant whenSent = Instant.now().truncatedTo(ChronoUnit.MILLIS);
+ long deliberatelyOldWriteTimestamp = toEpochMicros(whenSent.minus(Duration.ofDays(60)));
+ session.execute(String.format(
+ "insert into testspace.message (sender, receiver, message, when_sent) values "
+ + "('update.timestamp.sender', 'update.timestamp.receiver', 'original', %d) using timestamp %d",
+ whenSent.toEpochMilli(), deliberatelyOldWriteTimestamp));
+ Thread.sleep(250);
+
+ RecordSchema schema = getMessageSchema();
+ Map map = new HashMap<>();
+ map.put("sender", "update.timestamp.sender");
+ map.put("receiver", "update.timestamp.receiver");
+ map.put("message", "updated");
+ map.put("when_sent", whenSent);
+
+ MapRecord record = new MapRecord(schema, map);
+ List updateKeys = List.of("sender", "receiver", "when_sent");
+
+ sessionProvider.update(new QualifiedTableName(null, "message"), record, Map.of(), updateKeys, UpdateMethod.SET, new WriteOverrides(null, "when_sent"));
+
+ long writeTimeMicros = session.execute(String.format(
+ "select WRITETIME(message) as write_time from testspace.message where sender = 'update.timestamp.sender' "
+ + "and receiver = 'update.timestamp.receiver' and when_sent = %d", whenSent.toEpochMilli()))
+ .iterator().next().getLong("write_time");
+
+ assertEquals(toEpochMicros(whenSent), writeTimeMicros);
+ }
+
+ private long writeTimeForBatchTimestampSender(String receiver) {
+ return session.execute(String.format(
+ "select WRITETIME(message) as write_time from testspace.message where sender = 'batch.timestamp.sender' and receiver = '%s'", receiver))
+ .iterator().next().getLong("write_time");
+ }
+
+ private static long toEpochMicros(Instant instant) {
+ return instant.getEpochSecond() * 1_000_000L + instant.getNano() / 1_000L;
+ }
+
+ @Test
+ @DisplayName("A Timestamp Field override is ignored for counter updates, since Cassandra/ScyllaDB do not support a custom write timestamp on counter columns")
+ void testCounterUpdateIgnoresTimestampFieldOverride() {
+ RecordField field1 = new RecordField("column_a", RecordFieldType.STRING.getDataType());
+ RecordField field2 = new RecordField("increment_field", RecordFieldType.INT.getDataType());
+ RecordSchema schema = new SimpleRecordSchema(List.of(field1, field2));
+
+ Map map = new HashMap<>();
+ map.put("column_a", "timestamp-ignored-for-counters");
+ map.put("increment_field", 1);
+
+ MapRecord record = new MapRecord(schema, map);
+ List updateKeys = List.of("column_a");
+
+ // "column_a" isn't a timestamp-typed field, but it doesn't matter: if the override were mistakenly
+ // applied to a counter UPDATE, Cassandra/ScyllaDB would reject the statement outright regardless of
+ // what value was supplied ("Cannot provide custom timestamp for a BATCH containing counters" /
+ // similar), so not throwing here proves the override was correctly skipped before ever reading the field.
+ assertDoesNotThrow(() -> sessionProvider.update(new QualifiedTableName(null, "counter_test"), record, Map.of(), updateKeys, UpdateMethod.INCREMENT, new WriteOverrides(null, "column_a")));
+ }
+
+ @Test
+ @DisplayName("A TTL override is ignored for counter updates, since Cassandra/ScyllaDB do not support a TTL on counter columns")
+ void testCounterUpdateIgnoresTtlOverride() {
+ RecordField field1 = new RecordField("column_a", RecordFieldType.STRING.getDataType());
+ RecordField field2 = new RecordField("increment_field", RecordFieldType.INT.getDataType());
+ RecordSchema schema = new SimpleRecordSchema(List.of(field1, field2));
+
+ Map map = new HashMap<>();
+ map.put("column_a", "ttl-ignored-for-counters");
+ map.put("increment_field", 1);
+
+ MapRecord record = new MapRecord(schema, map);
+ List updateKeys = List.of("column_a");
+
+ // If the TTL were mistakenly applied to a counter UPDATE, Cassandra/ScyllaDB would reject the
+ // statement outright ("Cannot set ttl on a counter column"), so simply not throwing here proves
+ // the override was correctly skipped.
+ assertDoesNotThrow(() -> sessionProvider.update(new QualifiedTableName(null, "counter_test"), record, Map.of(), updateKeys,
+ UpdateMethod.INCREMENT, new WriteOverrides(Duration.ofSeconds(100), null)));
+ }
+
+ @Test
+ @DisplayName("Updating a record with the SET method overwrites the existing column value")
+ void testUpdateSet() throws Exception {
+ session.execute("insert into testspace.simple_set_test(username, is_active) values('john.smith', true)");
+ Thread.sleep(250);
+
+ RecordField field1 = new RecordField("username", RecordFieldType.STRING.getDataType());
+ RecordField field2 = new RecordField("is_active", RecordFieldType.BOOLEAN.getDataType());
+ RecordSchema schema = new SimpleRecordSchema(List.of(field1, field2));
+
+ Map map = new HashMap<>();
+ map.put("username", "john.smith");
+ map.put("is_active", false);
+
+ MapRecord record = new MapRecord(schema, map);
+
+ List updateKeys = new ArrayList<>();
+ updateKeys.add("username");
+
+ sessionProvider.update(new QualifiedTableName(null, "simple_set_test"), record, Map.of(), updateKeys, UpdateMethod.SET, WriteOverrides.NONE);
+
+ Iterator iterator = session.execute("select is_active from testspace.simple_set_test where username = 'john.smith'").iterator();
+
+ Row row = iterator.next();
+
+ assertFalse(row.getBoolean("is_active"));
+ }
+
+ @Test
+ @DisplayName("Querying rows via the CQL execution service returns them without error")
+ void testQueryRecord() {
+ String[] statements = """
+ insert into testspace.query_test (column_a, column_b, when)
+ values ('abc', 'def', toTimestamp(now()));
+ insert into testspace.query_test (column_a, column_b, when)
+ values ('abc', 'ghi', toTimestamp(now()));
+ insert into testspace.query_test (column_a, column_b, when)
+ values ('abc', 'jkl', toTimestamp(now()));
+ """.trim().split("\\;");
+ for (String statement : statements) {
+ session.execute(statement);
+ }
+
+ CollectingCqlQueryCallback callback = new CollectingCqlQueryCallback();
+
+ sessionProvider.query("select * from testspace.query_test", null, callback, QueryOverrides.NONE);
+
+ assertTrue(callback.getRecords().size() >= statements.length,
+ () -> "Expected at least the " + statements.length + " inserted rows back, got " + callback.getRecords().size());
+ }
+
+ @Test
+ @DisplayName("A query that fails on the driver's initial execute() surfaces as QueryFailureException, not a raw driver exception")
+ void testQueryExecutionFailureOnInitialExecuteIsWrappedAsQueryFailureException() throws Exception {
+ // A consistency level of THREE can never be satisfied by this single-node cluster, so the driver
+ // throws UnavailableException (a QueryExecutionException) synchronously from the very first
+ // execute() call, before any rows are fetched - exactly the code path that used to bypass the
+ // QueryFailureException translation.
+ final CQLExecutionService unsatisfiableConsistencyProvider = CqlServiceRunner.forService(newSessionProvider())
+ .withConnection(connectionInfo)
+ .withProperty(AbstractCQLExecutionService.CONSISTENCY_LEVEL, "THREE")
+ .enable();
+
+ final CollectingCqlQueryCallback callback = new CollectingCqlQueryCallback();
+
+ assertThrows(QueryFailureException.class, () -> unsatisfiableConsistencyProvider.query(
+ "select * from testspace.query_test", null, callback, QueryOverrides.NONE));
+
+ assertTrue(callback.getRecords().isEmpty(), "A query that never executed should not have delivered rows");
+ }
+
+ // ---- execute(): conditional writes and schema-free reads -------------------------------------------
+ //
+ // These are the paths query() cannot express: a statement-level outcome, and values read without a
+ // schema imposed on them. Both matter for a caller doing compare-and-set over opaque bytes.
+
+ /**
+ * The {@code keyspace.table} name the {@code execute()} tests below share. The table itself is created
+ * once in {@link #initializeSessionProvider(CqlConnectionInfo)}, before the service session exists, so
+ * that session sees it on connect rather than racing a lazy create against its schema metadata.
+ */
+ private String kvTable() {
+ return kvTableName;
+ }
+
+ private static ByteBuffer bytes(final String value) {
+ return ByteBuffer.wrap(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ @DisplayName("An unconditional statement reports wasApplied, so a caller that never writes conditionally has no special case")
+ void testExecuteUnconditionalWriteIsApplied() {
+ final String table = kvTable();
+
+ final CqlStatementResult result = sessionProvider.execute("insert into " + table + " (k, v) values (?, ?)",
+ List.of(bytes("uncond"), bytes("value")), QueryOverrides.NONE);
+
+ assertTrue(result.wasApplied());
+ assertTrue(result.rows().isEmpty(), "an unconditional insert returns no rows");
+ }
+
+ @Test
+ @DisplayName("A conditional write that applies reports true and returns no row, since there was no prior value")
+ void testExecuteConditionalWriteApplied() {
+ final String table = kvTable();
+
+ final CqlStatementResult result = sessionProvider.execute(
+ "insert into " + table + " (k, v) values (?, ?) if not exists",
+ List.of(bytes("lwt-new"), bytes("first")), QueryOverrides.NONE);
+
+ assertTrue(result.wasApplied());
+ assertTrue(result.rows().isEmpty(),
+ () -> "expected no rows for an applied conditional write, got " + result.rows().size());
+ }
+
+ @Test
+ @DisplayName("A conditional write that is rejected returns the stored row, which is what makes compare-and-set one round trip")
+ void testExecuteConditionalWriteRejectedCarriesStoredRow() {
+ final String table = kvTable();
+ sessionProvider.execute("insert into " + table + " (k, v) values (?, ?)",
+ List.of(bytes("lwt-existing"), bytes("original")), QueryOverrides.NONE);
+
+ final CqlStatementResult result = sessionProvider.execute(
+ "insert into " + table + " (k, v) values (?, ?) if not exists",
+ List.of(bytes("lwt-existing"), bytes("attempted")), QueryOverrides.NONE);
+
+ assertFalse(result.wasApplied());
+ assertEquals(1, result.rows().size());
+
+ final CqlRow stored = result.rows().getFirst();
+ assertArrayEquals("original".getBytes(StandardCharsets.UTF_8), stored.getBytes("v"),
+ "the losing writer must see the value that is actually stored");
+
+ // The outcome is reported by wasApplied(), not as data: "[applied]" is not a legal identifier, and
+ // surfacing it as a column would push that problem onto every caller.
+ assertFalse(stored.columnNames().contains("[applied]"),
+ () -> "the outcome column leaked into the row: " + stored.columnNames());
+ }
+
+ @Test
+ @DisplayName("A conditional delete reports whether it applied and returns the row it removed")
+ void testExecuteConditionalDelete() {
+ final String table = kvTable();
+ sessionProvider.execute("insert into " + table + " (k, v) values (?, ?)",
+ List.of(bytes("to-delete"), bytes("doomed")), QueryOverrides.NONE);
+
+ final CqlStatementResult deleted = sessionProvider.execute(
+ "delete from " + table + " where k = ? if exists", List.of(bytes("to-delete")), QueryOverrides.NONE);
+ assertTrue(deleted.wasApplied());
+
+ final CqlStatementResult again = sessionProvider.execute(
+ "delete from " + table + " where k = ? if exists", List.of(bytes("to-delete")), QueryOverrides.NONE);
+ assertFalse(again.wasApplied(), "deleting an absent row must not report as applied");
+ }
+
+ @Test
+ @DisplayName("Blob values round-trip as bytes, with no schema imposed on an encoding the caller owns")
+ void testExecuteBlobRoundTrip() {
+ final String table = kvTable();
+ final byte[] payload = "opaque bytes".getBytes(StandardCharsets.UTF_8);
+
+ sessionProvider.execute("insert into " + table + " (k, v) values (?, ?)",
+ List.of(bytes("roundtrip"), ByteBuffer.wrap(payload)), QueryOverrides.NONE);
+
+ final CqlStatementResult result = sessionProvider.execute("select k, v from " + table + " where k = ?",
+ List.of(bytes("roundtrip")), QueryOverrides.NONE);
+
+ assertEquals(1, result.rows().size());
+ assertArrayEquals(payload, result.rows().getFirst().getBytes("v"));
+ }
+
+ @Test
+ @DisplayName("Cells arrive in selection order, and a column name that is not a legal identifier survives intact")
+ void testExecuteExposesCellsInSelectionOrder() {
+ final String table = kvTable();
+ sessionProvider.execute("insert into " + table + " (k, v) values (?, ?)",
+ List.of(bytes("cells"), bytes("value")), QueryOverrides.NONE);
+
+ final CqlStatementResult result = sessionProvider.execute(
+ "select v, writetime(v), k from " + table + " where k = ?", List.of(bytes("cells")), QueryOverrides.NONE);
+
+ final CqlRow row = result.rows().getFirst();
+ assertEquals(List.of("v", "writetime(v)", "k"), row.columnNames(),
+ "selection order is the only ordering information a schema-free caller has");
+ assertNotNull(row.getObject("writetime(v)"),
+ "a projected function's column name is not a legal identifier, and must survive anyway");
+ }
+
+ @Test
+ @DisplayName("A query returning no rows yields an empty result rather than a null one")
+ void testExecuteEmptyResult() {
+ final String table = kvTable();
+
+ final CqlStatementResult result = sessionProvider.execute("select v from " + table + " where k = ?",
+ List.of(bytes("never-written")), QueryOverrides.NONE);
+
+ assertTrue(result.rows().isEmpty());
+ assertTrue(result.wasApplied(), "a plain SELECT is unconditional");
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlRecordFieldTypeIT.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlRecordFieldTypeIT.java
new file mode 100644
index 000000000000..bae587aff643
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlRecordFieldTypeIT.java
@@ -0,0 +1,359 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import com.datastax.oss.driver.api.core.CqlSession;
+import com.datastax.oss.driver.api.core.data.UdtValue;
+import com.datastax.oss.driver.api.core.uuid.Uuids;
+import org.apache.nifi.serialization.SimpleRecordSchema;
+import org.apache.nifi.serialization.record.DataType;
+import org.apache.nifi.serialization.record.MapRecord;
+import org.apache.nifi.serialization.record.RecordField;
+import org.apache.nifi.serialization.record.RecordFieldType;
+import org.apache.nifi.serialization.record.RecordSchema;
+import org.apache.nifi.service.cql.api.metadata.QualifiedTableName;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.service.cql.api.service.CQLQueryCallback;
+import org.apache.nifi.service.cql.api.service.QueryOverrides;
+import org.apache.nifi.service.cql.api.service.WriteOverrides;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.sql.Date;
+import java.sql.Time;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/**
+ * Exercises {@link CQLExecutionService#insert(String, org.apache.nifi.serialization.record.Record, WriteOverrides)}
+ * and {@link CQLExecutionService#query(String, boolean, List, CQLQueryCallback, QueryOverrides)} for the
+ * record-field-type to CQL-type pairings that only a real cluster can settle, against whichever backend the
+ * concrete subclass wires up. Every case follows the same shape: write a record, assert the write succeeds,
+ * read the row back, and assert the value comes back as the type the column holds.
+ *
+ * What stays here: the UDT cases, whose {@code Record}-to-{@code UdtValue} conversion is driven by the
+ * driver's live, server-resolved type metadata (a fabricated {@code UserDefinedType} cannot stand in for
+ * that); one round trip each for a list, a set, and a map; the two {@code java.sql.Date}/{@code Time} rows,
+ * which bind through a custom codec but read back through the driver's default, so only a real round trip
+ * shows the write and the read stay consistent across that asymmetry; and one canonical scalar plus one
+ * representative coercion as an end-to-end smoke test of the write/read path.
+ *
+ *
What moved out: the rest of the scalar and coercion grid. Every {@code Flexible*Codec} widening is
+ * covered byte-for-byte in {@code FlexibleCodecTest} and {@code CharacterCodecTest}, timestamp parsing in
+ * {@code CassandraCQLExecutionServiceBindValueTest}, and {@code convertForCqlType}'s {@code Object[]}
+ * handling and the {@code timeuuid} rejection of a non-version-1 UUID in
+ * {@code CassandraCQLExecutionServiceWritePathTest}. Running those against a container was a slow,
+ * Docker-gated copy of coverage that already exists without one.
+ *
+ * Setup is a plain protected method rather than a JUnit lifecycle callback for the same reason documented on
+ * {@link AbstractCqlCrudIT}.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public abstract class AbstractCqlRecordFieldTypeIT {
+
+ protected CQLExecutionService sessionProvider;
+
+ private CqlSession session;
+
+ private String keyspace;
+
+ /**
+ * @return a fresh, unconfigured instance of the backend implementation under test
+ */
+ protected abstract CQLExecutionService newSessionProvider();
+
+ protected void initializeSessionProvider(final CqlConnectionInfo connectionInfo) throws Exception {
+ this.session = connectionInfo.session();
+ this.keyspace = connectionInfo.keyspace();
+ this.sessionProvider = CqlServiceRunner.forService(newSessionProvider())
+ .withConnection(connectionInfo)
+ .enable();
+ }
+
+ /** Idempotent DDL, retried on the failures that say nothing about whether it ran - see {@link CqlDdl}. */
+ private void executeDdlWithRetry(final String cql) {
+ CqlDdl.executeWithRetry(session, cql);
+ }
+
+ private void createTable(final String tableName, final String cqlColumnType) {
+ executeDdlWithRetry(String.format("create table if not exists %s.%s (id int primary key, value_field %s)", keyspace, tableName, cqlColumnType));
+ }
+
+ private RecordSchema schemaFor(final RecordField valueField) {
+ return new SimpleRecordSchema(List.of(new RecordField("id", RecordFieldType.INT.getDataType()), valueField));
+ }
+
+ /**
+ * Reads back exactly one row via {@link CQLExecutionService#query}, asserting the query itself doesn't
+ * throw and that exactly one row came back, then returns it for the caller to assert on.
+ */
+ private org.apache.nifi.serialization.record.Record readBack(final String tableName, final String columns, final int id) {
+ final CollectingCqlQueryCallback callback = new CollectingCqlQueryCallback();
+
+ assertDoesNotThrow(() -> sessionProvider.query(
+ String.format("select %s from %s.%s where id = %d", columns, keyspace, tableName, id), null, callback, QueryOverrides.NONE));
+
+ final List results = callback.getRecords();
+ assertEquals(1, results.size(), () -> "Expected exactly one row back from " + tableName);
+ return results.getFirst();
+ }
+
+ /**
+ * The single-column type matrix: one row per (record field type, CQL column type) pairing, each writing a
+ * value and reading it back.
+ *
+ * {@code written} and {@code expected} differ for the rows that exist to prove coercion happens - a
+ * value handed to the service in one Java type must come back in the type the column actually holds. Where
+ * they are the same object the row is a plain round-trip.
+ *
+ *
Types whose handling needs more than a value and a comparison - collections, UDTs, and the
+ * timeuuid rejection - keep their own methods below; forcing them into this shape would hide what they
+ * assert.
+ */
+ static Stream singleColumnTypes() {
+ final Instant timestamp = Instant.now().truncatedTo(ChronoUnit.MILLIS);
+ final UUID timeUuid = Uuids.timeBased();
+ final Date sqlDate = Date.valueOf(LocalDate.of(2024, 3, 15));
+ final Time sqlTime = Time.valueOf(LocalTime.of(13, 45, 30));
+ final Map map = Map.of("key1", "value1", "key2", "value2");
+
+ return Stream.of(
+ // One canonical scalar round trip, as an end-to-end smoke test of the write/read path. The
+ // rest of the scalar grid, and every Flexible*Codec widening, is covered without a container
+ // in FlexibleCodecTest, CharacterCodecTest, and CassandraCQLExecutionServiceBindValueTest.
+ arguments("STRING -> text", "string_test", "text", RecordFieldType.STRING.getDataType(), "hello world", "hello world"),
+
+ // CQL timestamp has millisecond resolution, so the written value is truncated up front to make
+ // the round-trip comparison exact.
+ arguments("TIMESTAMP -> timestamp", "timestamp_test", "timestamp", RecordFieldType.TIMESTAMP.getDataType(), timestamp, timestamp),
+
+ // A timeuuid column requires a genuine version-1 UUID; the rejection of any other version is
+ // unit-tested in CassandraCQLExecutionServiceWritePathTest.
+ arguments("UUID (v1) -> timeuuid", "timeuuid_test", "timeuuid", RecordFieldType.UUID.getDataType(), timeUuid, timeUuid),
+
+ // One representative coercion end to end: a value handed in as text must land as - and read
+ // back as - the type the column actually holds.
+ arguments("INT <- String", "int_from_string_test", "int", RecordFieldType.INT.getDataType(), "123456", 123456),
+
+ // java.sql.Date/Time bind through JavaSQLDateCodec and JavaSQLTimeCodec, but reads go through the
+ // driver's default DATE/TIME codecs: registering those codecs adds a way to bind the java.sql
+ // type, it does not replace the driver's default for an untyped read like Row.getObject(int).
+ // Only a real round trip shows the bind path and the read path stay consistent.
+ arguments("DATE <- java.sql.Date", "date_coercion_test", "date", RecordFieldType.DATE.getDataType(), sqlDate, sqlDate.toLocalDate()),
+ arguments("TIME <- java.sql.Time", "time_coercion_test", "time", RecordFieldType.TIME.getDataType(), sqlTime, sqlTime.toLocalTime()),
+
+ // A collection column: convertForCqlType's element recursion plus the server's own map encoding.
+ arguments("MAP -> map", "map_test", "map",
+ RecordFieldType.MAP.getMapDataType(RecordFieldType.STRING.getDataType()), map, map));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("singleColumnTypes")
+ @DisplayName("A record field writes to its CQL column and reads back as the value the column holds")
+ void testSingleColumnType(final String description, final String tableName, final String cqlColumnType,
+ final DataType dataType, final Object written, final Object expected) {
+ createTable(tableName, cqlColumnType);
+ final RecordSchema schema = schemaFor(new RecordField("value_field", dataType));
+ final MapRecord record = new MapRecord(schema, Map.of("id", 1, "value_field", written));
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, tableName), record, Map.of(), WriteOverrides.NONE));
+
+ assertEquals(expected, readBack(tableName, "value_field", 1).getValue("value_field"));
+ }
+
+ @Test
+ @DisplayName("A record with an ARRAY of STRING field holding a List writes to and reads back from a list column")
+ void testArray() {
+ createTable("array_test", "list");
+ final RecordSchema schema = schemaFor(
+ new RecordField("value_field", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.STRING.getDataType())));
+ final List expected = List.of("a", "b", "c");
+ final MapRecord record = new MapRecord(schema, Map.of("id", 1, "value_field", expected));
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "array_test"), record, Map.of(), WriteOverrides.NONE));
+
+ assertEquals(expected, readBack("array_test", "value_field", 1).getValue("value_field"));
+ }
+
+ @Test
+ @DisplayName("A record with an ARRAY of STRING field holding a Set writes to and reads back from a set column")
+ void testSetOfString() {
+ createTable("set_test", "set");
+ final RecordSchema schema = schemaFor(
+ new RecordField("value_field", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.STRING.getDataType())));
+ final Set expected = Set.of("a", "b", "c");
+ final MapRecord record = new MapRecord(schema, Map.of("id", 1, "value_field", expected));
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "set_test"), record, Map.of(), WriteOverrides.NONE));
+
+ assertEquals(expected, readBack("set_test", "value_field", 1).getValue("value_field"));
+ }
+
+ @Test
+ @DisplayName("A record with a MAP of RECORD field writes a map of UDT values to and reads it back")
+ void testMapOfAddressUserDefinedType() {
+ executeDdlWithRetry("create type if not exists " + keyspace + ".address_map_item (street_address text, state text, zip_code int)");
+ executeDdlWithRetry(String.format("create table if not exists %s.address_map_test (id int primary key, addresses map>)", keyspace));
+
+ final RecordSchema addressSchema = new SimpleRecordSchema(List.of(
+ new RecordField("street_address", RecordFieldType.STRING.getDataType()),
+ new RecordField("state", RecordFieldType.STRING.getDataType()),
+ new RecordField("zip_code", RecordFieldType.INT.getDataType())
+ ));
+ final RecordSchema schema = schemaFor(new RecordField("addresses",
+ RecordFieldType.MAP.getMapDataType(RecordFieldType.RECORD.getRecordDataType(addressSchema))));
+
+ final MapRecord home = new MapRecord(addressSchema, Map.of("street_address", "123 Main St", "state", "NC", "zip_code", 27601));
+ final MapRecord work = new MapRecord(addressSchema, Map.of("street_address", "456 Elm St", "state", "SC", "zip_code", 29401));
+ final MapRecord record = new MapRecord(schema, Map.of("id", 1, "addresses", Map.of("home", home, "work", work)));
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "address_map_test"), record, Map.of(), WriteOverrides.NONE));
+
+ final Object addresses = readBack("address_map_test", "addresses", 1).getValue("addresses");
+ assertInstanceOf(Map.class, addresses);
+ @SuppressWarnings("unchecked")
+ final Map addressMap = (Map) addresses;
+ assertEquals("123 Main St", addressMap.get("home").getString("street_address"));
+ assertEquals("456 Elm St", addressMap.get("work").getString("street_address"));
+ }
+
+ @Test
+ @DisplayName("A record with a RECORD field for a Home Address writes a UDT and reads it back")
+ void testHomeAddressUserDefinedType() {
+ executeDdlWithRetry("create type if not exists " + keyspace + ".home_address (street_address text, state text, zip_code int)");
+ executeDdlWithRetry(String.format("create table if not exists %s.home_address_test (id int primary key, home_address frozen)", keyspace));
+
+ final RecordSchema addressSchema = new SimpleRecordSchema(List.of(
+ new RecordField("street_address", RecordFieldType.STRING.getDataType()),
+ new RecordField("state", RecordFieldType.STRING.getDataType()),
+ new RecordField("zip_code", RecordFieldType.INT.getDataType())
+ ));
+ final RecordSchema schema = schemaFor(new RecordField("home_address", RecordFieldType.RECORD.getRecordDataType(addressSchema)));
+
+ // A plain nested MapRecord, exactly like what a RecordReader (JSON, Avro, etc.) would produce for a
+ // nested object - the session provider is responsible for converting this into the UdtValue the
+ // DataStax driver's codec requires.
+ final MapRecord homeAddress = new MapRecord(addressSchema, Map.of(
+ "street_address", "123 Main St",
+ "state", "NC",
+ "zip_code", 27601));
+
+ final MapRecord record = new MapRecord(schema, Map.of("id", 1, "home_address", homeAddress));
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "home_address_test"), record, Map.of(), WriteOverrides.NONE));
+
+ final Object readValue = readBack("home_address_test", "home_address", 1).getValue("home_address");
+ assertInstanceOf(UdtValue.class, readValue);
+ final UdtValue udtValue = (UdtValue) readValue;
+ assertEquals("123 Main St", udtValue.getString("street_address"));
+ assertEquals("NC", udtValue.getString("state"));
+ assertEquals(27601, udtValue.getInt("zip_code"));
+ }
+
+ @Test
+ @DisplayName("A record with a UDT nested inside another UDT writes to and reads back without any UDT-specific code")
+ void testPersonWithNestedAddressUserDefinedType() {
+ // Deliberately different names, field count, casing, and nesting depth than testHomeAddressUserDefinedType:
+ // this proves the RECORD-to-UdtValue conversion is driven entirely by the driver's live UDT metadata
+ // rather than any UDT-specific code, since it also has to recurse into a UDT nested inside another UDT.
+ // Quoting "firstName"/"lastName"/"zipCode" preserves their camelCase spelling; CQL would otherwise fold
+ // unquoted identifiers to lowercase.
+ executeDdlWithRetry("create type if not exists " + keyspace + ".address (street text, state text, \"zipCode\" int)");
+ executeDdlWithRetry("create type if not exists " + keyspace + ".person (\"firstName\" text, \"lastName\" text, address frozen)");
+ executeDdlWithRetry(String.format("create table if not exists %s.person_test (id int primary key, person frozen)", keyspace));
+
+ final RecordSchema addressSchema = new SimpleRecordSchema(List.of(
+ new RecordField("street", RecordFieldType.STRING.getDataType()),
+ new RecordField("state", RecordFieldType.STRING.getDataType()),
+ new RecordField("zipCode", RecordFieldType.INT.getDataType())
+ ));
+ final RecordSchema personSchema = new SimpleRecordSchema(List.of(
+ new RecordField("firstName", RecordFieldType.STRING.getDataType()),
+ new RecordField("lastName", RecordFieldType.STRING.getDataType()),
+ new RecordField("address", RecordFieldType.RECORD.getRecordDataType(addressSchema))
+ ));
+ final RecordSchema schema = schemaFor(new RecordField("person", RecordFieldType.RECORD.getRecordDataType(personSchema)));
+
+ final MapRecord address = new MapRecord(addressSchema, Map.of(
+ "street", "123 Main St",
+ "state", "NC",
+ "zipCode", 27601));
+ final MapRecord person = new MapRecord(personSchema, Map.of(
+ "firstName", "John",
+ "lastName", "Doe",
+ "address", address));
+ final MapRecord record = new MapRecord(schema, Map.of("id", 1, "person", person));
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "person_test"), record, Map.of(), WriteOverrides.NONE));
+
+ final Object readValue = readBack("person_test", "person", 1).getValue("person");
+ assertInstanceOf(UdtValue.class, readValue);
+ final UdtValue personValue = (UdtValue) readValue;
+ assertEquals("John", personValue.getString("firstName"));
+ assertEquals("Doe", personValue.getString("lastName"));
+ final UdtValue addressValue = personValue.getUdtValue("address");
+ assertEquals("123 Main St", addressValue.getString("street"));
+ assertEquals("NC", addressValue.getString("state"));
+ assertEquals(27601, addressValue.getInt("zipCode"));
+ }
+
+ @Test
+ @DisplayName("A record with an ARRAY of RECORD field writes a list of UDTs to and reads it back")
+ void testArrayOfAddressUserDefinedType() {
+ executeDdlWithRetry("create type if not exists " + keyspace + ".address_item (street_address text, state text, zip_code int)");
+ executeDdlWithRetry(String.format("create table if not exists %s.address_array_test (id int primary key, addresses list>)", keyspace));
+
+ final RecordSchema addressSchema = new SimpleRecordSchema(List.of(
+ new RecordField("street_address", RecordFieldType.STRING.getDataType()),
+ new RecordField("state", RecordFieldType.STRING.getDataType()),
+ new RecordField("zip_code", RecordFieldType.INT.getDataType())
+ ));
+ final RecordSchema schema = schemaFor(new RecordField("addresses",
+ RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.RECORD.getRecordDataType(addressSchema))));
+
+ final MapRecord addressOne = new MapRecord(addressSchema, Map.of("street_address", "123 Main St", "state", "NC", "zip_code", 27601));
+ final MapRecord addressTwo = new MapRecord(addressSchema, Map.of("street_address", "456 Elm St", "state", "SC", "zip_code", 29401));
+ final MapRecord record = new MapRecord(schema, Map.of("id", 1, "addresses", List.of(addressOne, addressTwo)));
+
+ assertDoesNotThrow(() -> sessionProvider.insert(new QualifiedTableName(null, "address_array_test"), record, Map.of(), WriteOverrides.NONE));
+
+ final Object addresses = readBack("address_array_test", "addresses", 1).getValue("addresses");
+ assertInstanceOf(List.class, addresses);
+ final List> addressList = (List>) addresses;
+ assertEquals(2, addressList.size());
+ assertInstanceOf(UdtValue.class, addressList.get(0));
+ assertEquals("123 Main St", ((UdtValue) addressList.get(0)).getString("street_address"));
+ assertEquals("456 Elm St", ((UdtValue) addressList.get(1)).getString("street_address"));
+ }
+
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlSecureVerificationIT.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlSecureVerificationIT.java
new file mode 100644
index 000000000000..61421f38037a
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/AbstractCqlSecureVerificationIT.java
@@ -0,0 +1,305 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.security.cert.builder.StandardCertificateBuilder;
+import org.apache.nifi.service.cql.api.service.AbstractCQLExecutionService;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.ssl.StandardSSLContextService;
+import org.apache.nifi.util.TestRunner;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Consumer;
+import javax.security.auth.x500.X500Principal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Every {@code verify()} scenario that needs a specially-provisioned server, against one container per
+ * backend: {@code PasswordAuthenticator} plus one-way TLS, holding an {@value #ADMIN_ROLE} role with a
+ * generated password and the {@value #KEYSPACE} keyspace. This replaces three separate suites - connection
+ * verification, authentication, SSL - that each booted their own container, because the session provider
+ * runs them all through the same {@code verify()} path and the only thing that differed was server config.
+ *
+ * The scenarios: a fully valid secure config succeeds every step; a wrong password and an untrusted
+ * server certificate each fail {@code Establish Connection}; a bogus datacenter fails {@code Verify
+ * Datacenter} (the driver builds a session with an unknown local datacenter and only fails when a statement
+ * forces node selection, which {@code verify()} does deliberately); an unknown keyspace fails {@code Verify
+ * Keyspace}; and a syntactically valid driver configuration file still connects. The one-way-vs-two-way TLS
+ * distinction is not exercised - the provider hands the driver a single {@code SSLContext} and never
+ * inspects it - and the {@code buildConfigLoader} composition itself is unit-tested without a container.
+ *
+ *
Gated behind {@link ConnectionTest}: runs only when {@value ConnectionTest#ENABLED_PROPERTY} is
+ * {@code true}, since a specially-configured container is a high price for coverage that a change to query
+ * or write behaviour cannot affect.
+ */
+@ConnectionTest
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public abstract class AbstractCqlSecureVerificationIT {
+
+ protected static final String ADMIN_ROLE = "admin";
+
+ protected static final String KEYSPACE = "testspace";
+
+ protected static final String LOCAL_DATACENTER = "datacenter1";
+
+ protected static final String STORE_TYPE = "PKCS12";
+
+ private static final String SSL_CONTEXT_SERVICE_ID = "ssl-context-service";
+
+ private static final Path SSL_ARTIFACT_DIRECTORY = createSslArtifactDirectory();
+
+ private SecureServer server;
+
+ private X509Certificate serverCertificate;
+
+ private String adminPassword;
+
+ /**
+ * @return a fresh, unconfigured instance of the backend implementation under test
+ */
+ protected abstract CQLExecutionService newSessionProvider();
+
+ /**
+ * Boots one container with {@code PasswordAuthenticator} and one-way TLS enabled using the given
+ * already-generated server certificate/key, creates an {@value #ADMIN_ROLE} role with {@code adminPassword}
+ * and the {@value #KEYSPACE} keyspace, and returns its contact point once it is ready for the test
+ * connections this class will make.
+ */
+ protected abstract SecureServer startSecureServer(KeyPair serverKeyPair, X509Certificate serverCertificate,
+ String adminRole, String adminPassword) throws Exception;
+
+ /**
+ * A running container's connection coordinates plus how to stop it, so the shared test methods never
+ * need to know the concrete container type.
+ */
+ public record SecureServer(String contactPoint, Runnable shutdown) {
+ }
+
+ /**
+ * A generated PKCS12 file together with the random password it was protected with. Reusable by concrete
+ * subclasses for the server's own keystore where the backend's server-side TLS is also keystore-based.
+ */
+ public record SslKeyStoreCredentials(Path path, String password) {
+ }
+
+ @BeforeAll
+ void startServer() throws Exception {
+ final KeyPair serverKeyPair = generateKeyPair();
+ serverCertificate = buildSelfSignedCertificate(serverKeyPair, "CN=cql-secure-server");
+ adminPassword = UUID.randomUUID().toString();
+ server = startSecureServer(serverKeyPair, serverCertificate, ADMIN_ROLE, adminPassword);
+ }
+
+ @AfterAll
+ void stopServer() {
+ if (server != null) {
+ server.shutdown().run();
+ }
+ }
+
+ @Test
+ @DisplayName("A fully valid secure configuration reports success for every verification step")
+ void testValidSecureConfigurationSucceeds() throws Exception {
+ final List results = verify(adminPassword, trustStoreFor(serverCertificate), runner -> { });
+
+ assertAllSuccessful(results);
+ assertTrue(results.stream().anyMatch(result -> "Establish Connection".equals(result.getVerificationStepName())));
+ assertTrue(results.stream().anyMatch(result -> "Verify Datacenter".equals(result.getVerificationStepName())));
+ assertTrue(results.stream().anyMatch(result -> "Verify Keyspace".equals(result.getVerificationStepName())));
+ }
+
+ @Test
+ @DisplayName("An incorrect password fails the connection step against PasswordAuthenticator")
+ void testIncorrectPasswordFailsConnection() throws Exception {
+ final List results =
+ verify(UUID.randomUUID().toString(), trustStoreFor(serverCertificate), runner -> { });
+
+ assertStepFailed(results, "Establish Connection");
+ }
+
+ @Test
+ @DisplayName("A truststore that does not trust the server certificate fails the connection step")
+ void testUntrustedServerCertificateFailsConnection() throws Exception {
+ // A certificate the server never presents - the client's trust anchor is simply wrong.
+ final X509Certificate unrelated = buildSelfSignedCertificate(generateKeyPair(), "CN=untrusted-server");
+
+ final List results = verify(adminPassword, trustStoreFor(unrelated), runner -> { });
+
+ assertStepFailed(results, "Establish Connection");
+ }
+
+ @Test
+ @DisplayName("A nonexistent datacenter fails the datacenter step, caught by the statement verify() forces")
+ void testInvalidDatacenterFailsDatacenterStep() throws Exception {
+ final List results = verify(adminPassword, trustStoreFor(serverCertificate),
+ runner -> runner.withProperty(AbstractCQLExecutionService.DATACENTER, "not-a-real-datacenter"));
+
+ assertStepFailed(results, "Verify Datacenter");
+ }
+
+ @Test
+ @DisplayName("A keyspace that does not exist fails the keyspace step")
+ void testNonexistentKeyspaceFailsKeyspaceStep() throws Exception {
+ final List results = verify(adminPassword, trustStoreFor(serverCertificate),
+ runner -> runner.withProperty(AbstractCQLExecutionService.KEYSPACE, "keyspace_that_does_not_exist"));
+
+ assertStepFailed(results, "Verify Keyspace");
+ }
+
+ @Test
+ @DisplayName("A syntactically valid driver configuration file still connects on top of auth and TLS")
+ void testDriverConfigurationFileSucceeds() throws Exception {
+ final Path configFile = Files.createTempFile("CqlDriverConfig", ".conf");
+ Files.writeString(configFile, """
+ datastax-java-driver {
+ basic.request.timeout = 15 seconds
+ }
+ """, StandardCharsets.UTF_8);
+ configFile.toFile().deleteOnExit();
+
+ final List results = verify(adminPassword, trustStoreFor(serverCertificate),
+ runner -> runner.withProperty(AbstractCQLExecutionService.DRIVER_CONFIGURATION_FILE, configFile.toString()));
+
+ assertAllSuccessful(results);
+ }
+
+ /**
+ * Runs {@code verify()} with the {@value #ADMIN_ROLE} credentials, the given password, and an SSL
+ * context service configured with the given truststore, applying {@code customizer} last so a scenario
+ * can override one property before verification.
+ */
+ private List verify(final String password, final SslKeyStoreCredentials trustStore,
+ final Consumer customizer) throws Exception {
+ final CqlServiceRunner serviceRunner = CqlServiceRunner.forService(newSessionProvider())
+ .withConnection(server.contactPoint(), LOCAL_DATACENTER, KEYSPACE)
+ .withProperty(AbstractCQLExecutionService.USERNAME, ADMIN_ROLE)
+ .withProperty(AbstractCQLExecutionService.PASSWORD, password);
+
+ final TestRunner runner = serviceRunner.runner();
+ final StandardSSLContextService sslContextService = new StandardSSLContextService();
+ runner.addControllerService(SSL_CONTEXT_SERVICE_ID, sslContextService);
+ runner.setProperty(sslContextService, StandardSSLContextService.TRUSTSTORE, trustStore.path().toString());
+ runner.setProperty(sslContextService, StandardSSLContextService.TRUSTSTORE_PASSWORD, trustStore.password());
+ runner.setProperty(sslContextService, StandardSSLContextService.TRUSTSTORE_TYPE, STORE_TYPE);
+ runner.enableControllerService(sslContextService);
+ serviceRunner.withProperty(AbstractCQLExecutionService.PROP_SSL_CONTEXT_SERVICE, SSL_CONTEXT_SERVICE_ID);
+
+ customizer.accept(serviceRunner);
+ return serviceRunner.verify();
+ }
+
+ private SslKeyStoreCredentials trustStoreFor(final X509Certificate certificate) throws Exception {
+ return writeTrustStore(certificate, "trusted");
+ }
+
+ private static void assertAllSuccessful(final List results) {
+ assertFalse(results.isEmpty());
+ for (final ConfigVerificationResult result : results) {
+ assertEquals(ConfigVerificationResult.Outcome.SUCCESSFUL, result.getOutcome(), result.getExplanation());
+ }
+ }
+
+ private static void assertStepFailed(final List results, final String stepName) {
+ final ConfigVerificationResult stepResult = results.stream()
+ .filter(result -> stepName.equals(result.getVerificationStepName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Expected a '" + stepName + "' verification result in " + results));
+
+ assertEquals(ConfigVerificationResult.Outcome.FAILED, stepResult.getOutcome(), stepResult.getExplanation());
+ }
+
+ // ---- TLS material helpers, shared with the concrete subclasses' server provisioning ----------------
+
+ protected static KeyPair generateKeyPair() throws Exception {
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ return keyPairGenerator.generateKeyPair();
+ }
+
+ protected static X509Certificate buildSelfSignedCertificate(final KeyPair keyPair, final String distinguishedName) {
+ return new StandardCertificateBuilder(keyPair, new X500Principal(distinguishedName), Duration.ofDays(1))
+ .setDnsSubjectAlternativeNames(List.of("localhost"))
+ .build();
+ }
+
+ protected static SslKeyStoreCredentials writeKeyStore(final PrivateKey privateKey, final X509Certificate certificate, final String alias) throws Exception {
+ final String password = generateStorePassword();
+ final KeyStore keyStore = KeyStore.getInstance(STORE_TYPE);
+ keyStore.load(null);
+ keyStore.setKeyEntry(alias, privateKey, password.toCharArray(), new X509Certificate[]{certificate});
+
+ final Path path = Files.createTempFile(SSL_ARTIFACT_DIRECTORY, alias + "-keystore", ".p12");
+ try (OutputStream outputStream = Files.newOutputStream(path)) {
+ keyStore.store(outputStream, password.toCharArray());
+ }
+ return new SslKeyStoreCredentials(path, password);
+ }
+
+ protected static SslKeyStoreCredentials writeTrustStore(final X509Certificate certificate, final String alias) throws Exception {
+ final String password = generateStorePassword();
+ final KeyStore trustStore = KeyStore.getInstance(STORE_TYPE);
+ trustStore.load(null);
+ trustStore.setCertificateEntry(alias, certificate);
+
+ final Path path = Files.createTempFile(SSL_ARTIFACT_DIRECTORY, alias + "-truststore", ".p12");
+ try (OutputStream outputStream = Files.newOutputStream(path)) {
+ trustStore.store(outputStream, password.toCharArray());
+ }
+ return new SslKeyStoreCredentials(path, password);
+ }
+
+ private static String generateStorePassword() {
+ final SecureRandom secureRandom = new SecureRandom();
+ final byte[] bytes = new byte[24];
+ secureRandom.nextBytes(bytes);
+ return HexFormat.of().formatHex(bytes);
+ }
+
+ // Written under the module's own target/ directory so "mvn clean" collects the generated stores rather
+ // than relying on deleteOnExit().
+ private static Path createSslArtifactDirectory() {
+ try {
+ return Files.createDirectories(Path.of("target", "cql-secure-it"));
+ } catch (final IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CollectingCqlQueryCallback.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CollectingCqlQueryCallback.java
new file mode 100644
index 000000000000..47708e4204d7
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CollectingCqlQueryCallback.java
@@ -0,0 +1,71 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import org.apache.nifi.serialization.record.Record;
+import org.apache.nifi.service.cql.api.service.CQLQueryCallback;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The {@link CQLQueryCallback} the integration tests read result sets through: it accumulates every row the
+ * service hands back, so a test can assert on what a query actually returned.
+ *
+ * {@link #clear()} discards the accumulated rows, mirroring what a real callback does when the service
+ * signals that a fatal error means the batch it has built up so far must be thrown away. The number of times
+ * it was called is recorded rather than swallowed, so a test can assert that a failing query cleaned up after
+ * itself - a no-op {@code clear()} would look identical to one that was never called at all.
+ */
+public class CollectingCqlQueryCallback implements CQLQueryCallback {
+ private final List records = new ArrayList<>();
+ private int clearCount;
+
+ @Override
+ public void receive(final long rowNumber, final Record result, final boolean hasMore) {
+ records.add(result);
+ }
+
+ @Override
+ public void clear() {
+ records.clear();
+ clearCount++;
+ }
+
+ /**
+ * Always {@code false}: these tests call the service directly, so there is no incoming FlowFile for this
+ * callback to have routed to {@code original}.
+ */
+ @Override
+ public boolean hasSentOriginal() {
+ return false;
+ }
+
+ /**
+ * @return the rows received so far, in the order the service delivered them
+ */
+ public List getRecords() {
+ return List.copyOf(records);
+ }
+
+ /**
+ * @return how many times {@link #clear()} has been called
+ */
+ public int getClearCount() {
+ return clearCount;
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/ConnectionTest.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/ConnectionTest.java
new file mode 100644
index 000000000000..d0b18099a71f
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/ConnectionTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks an integration suite as testing how a connection is established - authentication, TLS, and
+ * {@code verify()} - rather than what can be done once one exists. Suites so marked run only when
+ * {@value #ENABLED_PROPERTY} is set to {@code true}.
+ *
+ * These are the expensive suites and the ones least likely to be affected by a change to query or write
+ * behaviour: authentication and TLS each provision a dedicated container per scenario, because the server
+ * settings they exercise are baked into {@code cassandra.yaml}/{@code scylla.yaml} at startup and cannot be
+ * toggled on a running one. Skipping them by default keeps the routine integration run to the suites that
+ * exercise the code most changes actually touch.
+ *
+ *
Enable with:
+ *
{@code mvn verify -Pintegration-tests -DTEST_CQL_CONNECTION_TESTS=true}
+ *
+ * The condition is applied at class level, so a disabled suite never reaches {@code @BeforeAll} (or, on a
+ * {@code @ParameterizedClass}, {@code @BeforeParameterizedClassInvocation}) and therefore never starts a
+ * container. Being skipped rather than excluded also means the run still reports that they did not execute,
+ * instead of quietly showing a smaller test count.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Inherited
+@Documented
+@EnabledIfSystemProperty(named = ConnectionTest.ENABLED_PROPERTY, matches = "(?i)true")
+public @interface ConnectionTest {
+
+ /**
+ * The system property that opts these suites in. Named to match the {@code TEST_*} convention the other
+ * integration-test switches in this bundle already use ({@code TEST_CASSANDRA_OLDER_VERSIONS},
+ * {@code TEST_SCYLLA_DDL_TIMEOUT_SECONDS}).
+ */
+ String ENABLED_PROPERTY = "TEST_CQL_CONNECTION_TESTS";
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlConnectionInfo.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlConnectionInfo.java
new file mode 100644
index 000000000000..1567602de297
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlConnectionInfo.java
@@ -0,0 +1,32 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import com.datastax.oss.driver.api.core.CqlSession;
+
+/**
+ * Connection coordinates for a running backend container, resolved by a backend-specific JUnit
+ * extension and injected into {@code @BeforeAll}/{@code @Test} methods as a single parameter (a plain
+ * data holder, not a factory - each test still builds its own {@code TestRunner} inline as before).
+ *
+ * @param contactPoint "host:port" of the running container
+ * @param datacenter local datacenter name to use with the driver
+ * @param keyspace keyspace bootstrapped by the extension before tests run
+ * @param session a raw driver session, already connected, for out-of-band setup/verification
+ */
+public record CqlConnectionInfo(String contactPoint, String datacenter, String keyspace, CqlSession session) {
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlDdl.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlDdl.java
new file mode 100644
index 000000000000..5a094bb0d064
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlDdl.java
@@ -0,0 +1,62 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import com.datastax.oss.driver.api.core.CqlSession;
+import com.datastax.oss.driver.api.core.DriverTimeoutException;
+import com.datastax.oss.driver.api.core.connection.HeartbeatException;
+
+/**
+ * Executes schema-modifying statements against a real container, retrying the two failures that say nothing
+ * about whether the statement actually ran.
+ *
+ *
A {@link DriverTimeoutException} means the client stopped waiting for schema agreement, not that the
+ * server rejected the statement - ScyllaDB's Raft-based schema management (and, less often, Cassandra's own
+ * agreement protocol) can take longer than the configured request timeout on an otherwise healthy cluster. A
+ * {@link HeartbeatException} is the same story one layer down: the connection carrying the statement died
+ * mid-flight (observed on ScyllaDB under the load of a full-reactor IT run). Either way the statement may
+ * still land server-side moments later.
+ *
+ *
Every statement passed here must be idempotent - an "if not exists" form - since a
+ * retry after one of those false negatives must not fail with "already exists".
+ *
+ *
This lives in the shared IT module rather than beside any one suite because all three former copies of
+ * this loop wanted the same behaviour, and had drifted: two caught only the timeout, so they would flake on
+ * exactly the heartbeat failure the third already survived.
+ */
+public final class CqlDdl {
+
+ private static final int MAX_ATTEMPTS = 3;
+
+ private CqlDdl() {
+ }
+
+ public static void executeWithRetry(final CqlSession session, final String cql) {
+ RuntimeException lastFailure = null;
+
+ for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ session.execute(cql);
+ return;
+ } catch (final DriverTimeoutException | HeartbeatException e) {
+ lastFailure = e;
+ }
+ }
+
+ throw lastFailure;
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlServiceRunner.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlServiceRunner.java
new file mode 100644
index 000000000000..7c0d36208025
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/CqlServiceRunner.java
@@ -0,0 +1,106 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.service.cql.api.service.AbstractCQLExecutionService;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Wires a {@link CQLExecutionService} onto a {@link TestRunner} for an integration test.
+ *
+ *
Every IT in this suite needs the same four lines - a runner over {@link MockCqlProcessor}, the service
+ * added to it, and contact points/datacenter/keyspace set from a running container - before it can do
+ * anything interesting, and then diverges: some enable the service and use it, others only call
+ * {@code verify()} against a deliberately broken configuration. This collects the common part and leaves the
+ * divergence to {@link #enable()} versus {@link #verify()}.
+ *
+ *
Properties are set in call order and later calls overwrite earlier ones, so a test wanting all the
+ * usual connection settings except one writes {@code withConnection(info).withProperty(DATACENTER, "bogus")}
+ * rather than restating the other two.
+ */
+public final class CqlServiceRunner {
+
+ private static final String SERVICE_ID = "cql-session-provider";
+
+ private final TestRunner runner = TestRunners.newTestRunner(new MockCqlProcessor());
+
+ private final CQLExecutionService service;
+
+ private CqlServiceRunner(final CQLExecutionService service) throws InitializationException {
+ this.service = service;
+ runner.addControllerService(SERVICE_ID, service);
+ }
+
+ public static CqlServiceRunner forService(final CQLExecutionService service) throws InitializationException {
+ return new CqlServiceRunner(service);
+ }
+
+ /**
+ * Sets contact points, datacenter and keyspace from a running container.
+ */
+ public CqlServiceRunner withConnection(final CqlConnectionInfo connectionInfo) {
+ return withConnection(connectionInfo.contactPoint(), connectionInfo.datacenter(), connectionInfo.keyspace());
+ }
+
+ /**
+ * As {@link #withConnection(CqlConnectionInfo)}, for a test holding the coordinates loose rather than as
+ * a {@link CqlConnectionInfo} - the SSL and authentication suites provision their own containers and
+ * never build one.
+ */
+ public CqlServiceRunner withConnection(final String contactPoint, final String datacenter, final String keyspace) {
+ return withProperty(AbstractCQLExecutionService.CONTACT_POINTS, contactPoint)
+ .withProperty(AbstractCQLExecutionService.DATACENTER, datacenter)
+ .withProperty(AbstractCQLExecutionService.KEYSPACE, keyspace);
+ }
+
+ public CqlServiceRunner withProperty(final PropertyDescriptor descriptor, final String value) {
+ runner.setProperty(service, descriptor, value);
+ return this;
+ }
+
+ /**
+ * The underlying runner, for the occasional test that has to wire a second controller service the CQL
+ * service refers to (an SSL context, say) with setup too conditional to express here.
+ */
+ public TestRunner runner() {
+ return runner;
+ }
+
+ /**
+ * Enables the service and hands it back, ready to use.
+ */
+ public CQLExecutionService enable() {
+ runner.enableControllerService(service);
+ return service;
+ }
+
+ /**
+ * Runs {@code verify()} against the configuration built so far, without enabling the service - the point
+ * being that the configuration may be one that cannot work.
+ */
+ public List verify() {
+ return runner.verify(service, Map.of());
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/DockerUtils.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/DockerUtils.java
new file mode 100644
index 000000000000..4d302b005f12
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/DockerUtils.java
@@ -0,0 +1,49 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import com.github.dockerjava.api.command.CreateContainerCmd;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Docker-level container tuning that Testcontainers does not expose directly.
+ */
+public final class DockerUtils {
+
+ private DockerUtils() {
+ }
+
+ /**
+ * Caps what a test container may take from the host, so a database container cannot size itself against
+ * all of the machine's memory and cores.
+ *
+ * @param cpus CPU cores the container may use
+ * @param memoryGb memory ceiling in gibibytes
+ * @return a modifier to hand to {@code withCreateContainerCmdModifier}
+ */
+ public static Consumer createMemoryLimits(final long cpus, final long memoryGb) {
+ final long memoryLimitAsBytes = memoryGb * (1024L * 1024L * 1024L);
+ final long nanoCpus = cpus * 1_000_000_000L;
+ return cmd ->
+ Objects.requireNonNull(cmd.getHostConfig(), "HostConfig unexpectedly null")
+ .withMemory(memoryLimitAsBytes)
+ .withNanoCPUs(nanoCpus);
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/MockCqlProcessor.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/MockCqlProcessor.java
new file mode 100644
index 000000000000..ed6aeb00a8a1
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-it-common/src/test/java/org/apache/nifi/service/cql/it/MockCqlProcessor.java
@@ -0,0 +1,52 @@
+/*
+ * 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 org.apache.nifi.service.cql.it;
+
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Mock processor for exercising a {@link CQLExecutionService} controller service via {@code TestRunner},
+ * independent of which backend implementation (Cassandra, ScyllaDB) is under test.
+ */
+public class MockCqlProcessor extends AbstractProcessor {
+ private static final PropertyDescriptor CQL_SESSION_PROVIDER = new PropertyDescriptor.Builder()
+ .name("CQL Session Provider")
+ .required(true)
+ .description("Controller Service to obtain a CQL connection session")
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .identifiesControllerService(CQLExecutionService.class)
+ .build();
+
+ @Override
+ public List getSupportedPropertyDescriptors() {
+ return Collections.singletonList(CQL_SESSION_PROVIDER);
+ }
+
+ @Override
+ public void onTrigger(ProcessContext context, ProcessSession session) throws ProcessException {
+
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/pom.xml b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/pom.xml
new file mode 100644
index 000000000000..eac5154cfa78
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/pom.xml
@@ -0,0 +1,51 @@
+
+
+
+ 4.0.0
+
+
+ org.apache.nifi
+ nifi-cql-bundle
+ 2.12.0-SNAPSHOT
+
+
+ nifi-cql-nar
+ nar
+
+
+
+
+
+ com.google.guava
+ guava
+ provided
+
+
+
+
+
+
+ org.apache.nifi
+ nifi-cql-services-api-nar
+ 2.12.0-SNAPSHOT
+ nar
+
+
+ org.apache.nifi
+ nifi-cql-processors
+
+
+
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/src/main/resources/META-INF/LICENSE b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/src/main/resources/META-INF/LICENSE
new file mode 100644
index 000000000000..2672f6e36a1d
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/src/main/resources/META-INF/LICENSE
@@ -0,0 +1,342 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
+
+APACHE NIFI SUBCOMPONENTS:
+
+The Apache NiFi project contains subcomponents with separate copyright
+notices and license terms. Your use of the source code for the these
+subcomponents is subject to the terms and conditions of the following
+licenses.
+
+This product bundles 'libffi' which is available under an MIT style license.
+ libffi - Copyright (c) 1996-2014 Anthony Green, Red Hat, Inc and others.
+ see https://github.com/java-native-access/jna/blob/master/native/libffi/LICENSE
+
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ ``Software''), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED ``AS IS'', WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+This product bundles 'asm' which is available under a 3-Clause BSD style license.
+For details see http://asm.ow2.org/asmdex-license.html
+
+ Copyright (c) 2012 France Télécom
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ 3. Neither the name of the copyright holders nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ THE POSSIBILITY OF SUCH DAMAGE.
+
+ The binary distribution of this product bundles 'Bouncy Castle JDK 1.5'
+ under an MIT style license.
+
+ Copyright (c) 2000 - 2015 The Legion of the Bouncy Castle Inc. (http://www.bouncycastle.org)
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+
+The binary distribution of this product bundles 'JNR x86asm' under an MIT
+style license.
+
+ Copyright (C) 2010 Wayne Meissner
+ Copyright (c) 2008-2009, Petr Kobalicek
+
+ Permission is hereby granted, free of charge, to any person
+ obtaining a copy of this software and associated documentation
+ files (the "Software"), to deal in the Software without
+ restriction, including without limitation the rights to use,
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following
+ conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ OTHER DEALINGS IN THE SOFTWARE.
+
+ The binary distribution of this product bundles 'ParaNamer' and 'Paranamer Core'
+ which is available under a BSD style license.
+
+ Copyright (c) 2006 Paul Hammant & ThoughtWorks Inc
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+ 3. Neither the name of the copyright holders nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/src/main/resources/META-INF/NOTICE b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/src/main/resources/META-INF/NOTICE
new file mode 100644
index 000000000000..6bd6684a5156
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-nar/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,328 @@
+nifi-cassandra-nar
+Copyright 2016-2020 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+******************
+Apache Software License v2
+******************
+
+The following binary components are provided under the Apache Software License v2
+
+ (ASLv2) DataStax Java Driver for Apache Cassandra - Core
+ The following NOTICE information applies:
+ DataStax Java Driver for Apache Cassandra - Core
+ Copyright (C) 2012-2017 DataStax Inc.
+
+ (ASLv2) Apache Avro
+ The following NOTICE information applies:
+ Apache Avro
+ Copyright 2009-2017 The Apache Software Foundation
+
+ (ASLv2) Jackson JSON processor
+ The following NOTICE information applies:
+ # Jackson JSON processor
+
+ Jackson is a high-performance, Free/Open Source JSON processing library.
+ It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has
+ been in development since 2007.
+ It is currently developed by a community of developers, as well as supported
+ commercially by FasterXML.com.
+
+ ## Licensing
+
+ Jackson core and extension components may licensed under different licenses.
+ To find the details that apply to this artifact see the accompanying LICENSE file.
+ For more information, including possible other licensing options, contact
+ FasterXML.com (http://fasterxml.com).
+
+ ## Credits
+
+ A list of contributors may be found from CREDITS file, which is included
+ in some artifacts (usually source distributions); but is always available
+ from the source code management (SCM) system project uses.
+
+ (ASLv2) Apache Commons Codec
+ The following NOTICE information applies:
+ Apache Commons Codec
+ Copyright 2002-2014 The Apache Software Foundation
+
+ src/test/org/apache/commons/codec/language/DoubleMetaphoneTest.java
+ contains test data from http://aspell.net/test/orig/batch0.tab.
+ Copyright (C) 2002 Kevin Atkinson (kevina@gnu.org)
+
+ ===============================================================================
+
+ The content of package org.apache.commons.codec.language.bm has been translated
+ from the original php source code available at http://stevemorse.org/phoneticinfo.htm
+ with permission from the original authors.
+ Original source copyright:
+ Copyright (c) 2008 Alexander Beider & Stephen P. Morse.
+
+ (ASLv2) Apache Commons Compress
+ The following NOTICE information applies:
+ Apache Commons Compress
+ Copyright 2002-2017 The Apache Software Foundation
+
+ The files in the package org.apache.commons.compress.archivers.sevenz
+ were derived from the LZMA SDK, version 9.20 (C/ and CPP/7zip/),
+ which has been placed in the public domain:
+
+ "LZMA SDK is placed in the public domain." (http://www.7-zip.org/sdk.html)
+
+ (ASLv2) Apache Commons IO
+ The following NOTICE information applies:
+ Apache Commons IO
+ Copyright 2002-2016 The Apache Software Foundation
+
+ (ASLv2) Apache Commons Lang
+ The following NOTICE information applies:
+ Apache Commons Lang
+ Copyright 2001-2017 The Apache Software Foundation
+
+ This product includes software from the Spring Framework,
+ under the Apache License 2.0 (see: StringUtils.containsWhitespace())
+
+ (ASLv2) Guava
+ The following NOTICE information applies:
+ Guava
+ Copyright 2015 The Guava Authors
+
+ (ASLv2) Dropwizard Metrics
+ The following NOTICE information applies:
+ Copyright (c) 2010-2013 Coda Hale, Yammer.com
+
+ This product includes software developed by Coda Hale and Yammer, Inc.
+
+ This product includes code derived from the JSR-166 project (ThreadLocalRandom, Striped64,
+ LongAdder), which was released with the following comments:
+
+ Written by Doug Lea with assistance from members of JCP JSR-166
+ Expert Group and released to the public domain, as explained at
+ http://creativecommons.org/publicdomain/zero/1.0/
+
+ (ASLv2) The Netty Project
+ The following NOTICE information applies:
+ Copyright 2014 The Netty Project
+ -------------------------------------------------------------------------------
+ This product contains the extensions to Java Collections Framework which has
+ been derived from the works by JSR-166 EG, Doug Lea, and Jason T. Greene:
+
+ * LICENSE:
+ * license/LICENSE.jsr166y.txt (Public Domain)
+ * HOMEPAGE:
+ * http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/
+ * http://viewvc.jboss.org/cgi-bin/viewvc.cgi/jbosscache/experimental/jsr166/
+
+ This product contains a modified version of Robert Harder's Public Domain
+ Base64 Encoder and Decoder, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.base64.txt (Public Domain)
+ * HOMEPAGE:
+ * http://iharder.sourceforge.net/current/java/base64/
+
+ This product contains a modified portion of 'Webbit', an event based
+ WebSocket and HTTP server, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.webbit.txt (BSD License)
+ * HOMEPAGE:
+ * https://github.com/joewalnes/webbit
+
+ This product contains a modified portion of 'SLF4J', a simple logging
+ facade for Java, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.slf4j.txt (MIT License)
+ * HOMEPAGE:
+ * http://www.slf4j.org/
+
+ This product contains a modified portion of 'Apache Harmony', an open source
+ Java SE, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.harmony.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * http://archive.apache.org/dist/harmony/
+
+ This product contains a modified portion of 'jbzip2', a Java bzip2 compression
+ and decompression library written by Matthew J. Francis. It can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.jbzip2.txt (MIT License)
+ * HOMEPAGE:
+ * https://code.google.com/p/jbzip2/
+
+ This product contains a modified portion of 'libdivsufsort', a C API library to construct
+ the suffix array and the Burrows-Wheeler transformed string for any input string of
+ a constant-size alphabet written by Yuta Mori. It can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.libdivsufsort.txt (MIT License)
+ * HOMEPAGE:
+ * https://github.com/y-256/libdivsufsort
+
+ This product contains a modified portion of Nitsan Wakart's 'JCTools', Java Concurrency Tools for the JVM,
+ which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.jctools.txt (ASL2 License)
+ * HOMEPAGE:
+ * https://github.com/JCTools/JCTools
+
+ This product optionally depends on 'JZlib', a re-implementation of zlib in
+ pure Java, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.jzlib.txt (BSD style License)
+ * HOMEPAGE:
+ * http://www.jcraft.com/jzlib/
+
+ This product optionally depends on 'Compress-LZF', a Java library for encoding and
+ decoding data in LZF format, written by Tatu Saloranta. It can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.compress-lzf.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * https://github.com/ning/compress
+
+ This product optionally depends on 'lz4', a LZ4 Java compression
+ and decompression library written by Adrien Grand. It can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.lz4.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * https://github.com/jpountz/lz4-java
+
+ This product optionally depends on 'lzma-java', a LZMA Java compression
+ and decompression library, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.lzma-java.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * https://github.com/jponge/lzma-java
+
+ This product contains a modified portion of 'jfastlz', a Java port of FastLZ compression
+ and decompression library written by William Kinney. It can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.jfastlz.txt (MIT License)
+ * HOMEPAGE:
+ * https://code.google.com/p/jfastlz/
+
+ This product contains a modified portion of and optionally depends on 'Protocol Buffers', Google's data
+ interchange format, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.protobuf.txt (New BSD License)
+ * HOMEPAGE:
+ * https://github.com/google/protobuf
+
+ This product optionally depends on 'Bouncy Castle Crypto APIs' to generate
+ a temporary self-signed X.509 certificate when the JVM does not provide the
+ equivalent functionality. It can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.bouncycastle.txt (MIT License)
+ * HOMEPAGE:
+ * http://www.bouncycastle.org/
+
+ This product optionally depends on 'Snappy', a compression library produced
+ by Google Inc, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.snappy.txt (New BSD License)
+ * HOMEPAGE:
+ * https://github.com/google/snappy
+
+ This product optionally depends on 'JBoss Marshalling', an alternative Java
+ serialization API, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.jboss-marshalling.txt (GNU LGPL 2.1)
+ * HOMEPAGE:
+ * http://www.jboss.org/jbossmarshalling
+
+ This product optionally depends on 'Caliper', Google's micro-
+ benchmarking framework, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.caliper.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * https://github.com/google/caliper
+
+ This product optionally depends on 'Apache Log4J', a logging framework, which
+ can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.log4j.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * http://logging.apache.org/log4j/
+
+ This product optionally depends on 'Aalto XML', an ultra-high performance
+ non-blocking XML processor, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.aalto-xml.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * http://wiki.fasterxml.com/AaltoHome
+
+ This product contains a modified version of 'HPACK', a Java implementation of
+ the HTTP/2 HPACK algorithm written by Twitter. It can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.hpack.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * https://github.com/twitter/hpack
+
+ This product contains a modified portion of 'Apache Commons Lang', a Java library
+ provides utilities for the java.lang API, which can be obtained at:
+
+ * LICENSE:
+ * license/LICENSE.commons-lang.txt (Apache License 2.0)
+ * HOMEPAGE:
+ * https://commons.apache.org/proper/commons-lang/
+
+ This product contains a forked and modified version of Tomcat Native
+
+ * LICENSE:
+ * ASL2
+ * HOMEPAGE:
+ * http://tomcat.apache.org/native-doc/
+ * https://svn.apache.org/repos/asf/tomcat/native/
+
+ (ASLv2) Objenesis
+ The following NOTICE information applies:
+ Objenesis
+ Copyright 2006-2013 Joe Walnes, Henri Tremblay, Leonardo Mesquita
+
+ (ASLv2) Snappy Java
+ The following NOTICE information applies:
+ This product includes software developed by Google
+ Snappy: http://code.google.com/p/snappy/ (New BSD License)
+
+ This product includes software developed by Apache
+ PureJavaCrc32C from apache-hadoop-common http://hadoop.apache.org/
+ (Apache 2.0 license)
+
+ This library containd statically linked libstdc++. This inclusion is allowed by
+ "GCC RUntime Library Exception"
+ http://gcc.gnu.org/onlinedocs/libstdc++/manual/license.html
+
+************************
+Eclipse Public License 1.0
+************************
+
+The following binary components are provided under the Eclipse Public License 1.0. See project link for details.
+
+ (EPL 1.0) JNR Posix ( jnr.posix ) https://github.com/jnr/jnr-posix/blob/master/LICENSE.txt
+
+*****************
+Public Domain
+*****************
+
+The following binary components are provided to the 'Public Domain'. See project link for details.
+
+ (Public Domain) XZ for Java (org.tukaani:xz:jar:1.5 - http://tukaani.org/xz/java.html
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/pom.xml b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/pom.xml
new file mode 100644
index 000000000000..b6077d1f7681
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/pom.xml
@@ -0,0 +1,126 @@
+
+
+
+ 4.0.0
+
+
+ org.apache.nifi
+ nifi-cql-bundle
+ 2.12.0-SNAPSHOT
+
+
+ nifi-cql-processors
+ jar
+
+
+
+ org.apache.nifi
+ nifi-api
+
+
+ org.apache.nifi
+ nifi-utils
+
+
+ org.apache.nifi
+ nifi-properties
+
+
+ org.apache.nifi
+ nifi-ssl-context-service-api
+
+
+ org.apache.avro
+ avro
+
+
+ org.apache.nifi
+ nifi-cql-services-api
+ 2.12.0-SNAPSHOT
+ provided
+
+
+ org.apache.nifi
+ nifi-record-serialization-service-api
+ compile
+
+
+ org.apache.nifi
+ nifi-record
+ compile
+
+
+
+ org.apache.nifi
+ nifi-mock
+
+
+ org.apache.nifi
+ nifi-mock-record-utils
+
+
+ org.apache.commons
+ commons-text
+
+
+
+ org.apache.nifi
+ nifi-record-path
+ 2.12.0-SNAPSHOT
+ compile
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ ban-database-client-dependencies
+
+ enforce
+
+
+
+
+
+ The processors talk to a database only through the CQLExecutionService
+ abstraction, never through a driver directly, so no database client
+ library belongs on this module's classpath at any scope. If an
+ integration test here genuinely needs a live driver, add it to a module
+ that owns a backend instead.
+
+ true
+
+
+ org.apache.cassandra:java-driver-*
+ com.datastax.oss:*
+
+ com.datastax.cassandra:*
+
+ com.scylladb:*
+
+
+
+
+
+
+
+
+
+
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/AbstractCQLProcessor.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/AbstractCQLProcessor.java
new file mode 100644
index 000000000000..7a8790877848
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/AbstractCQLProcessor.java
@@ -0,0 +1,69 @@
+/*
+ * 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 org.apache.nifi.processors.cql;
+
+import org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * AbstractCQLProcessor is a base class for Cassandra processors and contains logic and variables common to most
+ * processors integrating with Apache Cassandra.
+ */
+public abstract class AbstractCQLProcessor extends AbstractProcessor {
+
+ // Common descriptors
+ static final PropertyDescriptor CONNECTION_PROVIDER_SERVICE = new PropertyDescriptor.Builder()
+ .name("Cassandra Connection Provider")
+ .description("Specifies the Cassandra connection providing controller service to be used to connect to Cassandra cluster.")
+ .required(true)
+ .identifiesControllerService(CQLExecutionService.class)
+ .build();
+
+ static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("A FlowFile is transferred to this relationship if the operation completed successfully.")
+ .build();
+
+ static final Relationship REL_FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("A FlowFile is transferred to this relationship if the operation failed.")
+ .build();
+
+ static final Relationship REL_RETRY = new Relationship.Builder().name("retry")
+ .description("A FlowFile is transferred to this relationship if the operation cannot be completed but attempting "
+ + "it again may succeed.")
+ .build();
+
+ protected final AtomicReference cqlSessionService = new AtomicReference<>(null);
+
+ @OnScheduled
+ public void onScheduled(ProcessContext context) {
+ CQLExecutionService sessionProvider = context.getProperty(CONNECTION_PROVIDER_SERVICE).asControllerService(CQLExecutionService.class);
+ cqlSessionService.set(sessionProvider);
+ }
+
+ public void stop(ProcessContext context) {
+
+ }
+}
+
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/ExecuteCQLQueryCallback.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/ExecuteCQLQueryCallback.java
new file mode 100644
index 000000000000..31c554d8bae1
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/ExecuteCQLQueryCallback.java
@@ -0,0 +1,225 @@
+/*
+ * 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 org.apache.nifi.processors.cql;
+
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.flowfile.attributes.FragmentAttributes;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.serialization.RecordSetWriter;
+import org.apache.nifi.serialization.RecordSetWriterFactory;
+import org.apache.nifi.serialization.record.RecordSchema;
+import org.apache.nifi.service.cql.api.service.CQLQueryCallback;
+
+import java.io.Closeable;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.apache.nifi.processors.cql.AbstractCQLProcessor.REL_SUCCESS;
+import static org.apache.nifi.processors.cql.ExecuteCQLQueryRecord.REL_ORIGINAL;
+
+public class ExecuteCQLQueryCallback implements CQLQueryCallback {
+ private final ProcessSession session;
+ private RecordSetWriterFactory writerFactory;
+ private RecordSetWriter recordWriter;
+ private ComponentLog logger;
+
+ private long currentIndex = 0;
+ private long rowsPerFlowFile;
+ private long flowFilesPerBatch;
+ private FlowFile parentFlowFile;
+
+ private boolean commitImmediately;
+ private final List flowFileBatch;
+ private FlowFile currentFlowFile;
+
+ private int fragmentIndex;
+ private UUID fragmentId;
+
+ public ExecuteCQLQueryCallback(FlowFile parentFlowFile,
+ RecordSetWriterFactory writerFactory,
+ ProcessSession session,
+ ComponentLog logger,
+ long rowsPerFlowfile,
+ long flowFilesPerBatch) {
+ this.parentFlowFile = parentFlowFile;
+ this.writerFactory = writerFactory;
+ this.session = session;
+ this.logger = logger;
+
+ this.commitImmediately = flowFilesPerBatch > 0;
+ this.rowsPerFlowFile = rowsPerFlowfile;
+ this.flowFilesPerBatch = flowFilesPerBatch;
+
+ this.flowFileBatch = new ArrayList<>();
+ this.fragmentIndex = 0;
+ this.fragmentId = UUID.randomUUID();
+ }
+
+ /**
+ * @return true if no rows were ever received, meaning no FlowFiles were created. The caller is responsible
+ * for routing the incoming FlowFile (if any) in that case, since this callback never gets a chance to.
+ */
+ public boolean isEmpty() {
+ return recordWriter == null;
+ }
+
+ private void updateFlowFileAttributes() {
+ Map attributes = Map.of(
+ FragmentAttributes.FRAGMENT_ID.key(), fragmentId.toString(),
+ FragmentAttributes.FRAGMENT_INDEX.key(), String.valueOf(fragmentIndex++),
+ "mime.type", recordWriter.getMimeType());
+
+ this.currentFlowFile = session.putAllAttributes(currentFlowFile, attributes);
+ flowFileBatch.add(currentFlowFile);
+ }
+
+ /**
+ * fragment.count can only be known once the whole result set has been processed. When Output Batch Size
+ * commits FlowFiles early, earlier FlowFiles are already released to REL_SUCCESS before the total is known,
+ * so fragment.count is intentionally left unset in that mode, matching the OUTPUT_BATCH_SIZE property
+ * description. Otherwise, every FlowFile in flowFileBatch is still held here, so the true total is known
+ * and applied to all of them.
+ */
+ private void finalizeFragmentCounts() {
+ if (!commitImmediately) {
+ final String totalFragmentCount = String.valueOf(flowFileBatch.size());
+ flowFileBatch.replaceAll(flowFile -> session.putAttribute(flowFile, FragmentAttributes.FRAGMENT_COUNT.key(), totalFragmentCount));
+ }
+ }
+
+ /**
+ * session.remove(FlowFile) refuses to remove a FlowFile with an open OutputStream/callback from
+ * session.write(FlowFile), so any writer or stream tied to a FlowFile being discarded on an error path
+ * must be closed first.
+ */
+ private void closeQuietly(Closeable closeable) {
+ if (closeable != null) {
+ try {
+ closeable.close();
+ } catch (Exception ex) {
+ logger.error("Error closing {}", closeable, ex);
+ }
+ }
+ }
+
+ private void removeUnfinishedFlowFiles() {
+ flowFileBatch.remove(currentFlowFile);
+ if (currentFlowFile != null) {
+ session.remove(currentFlowFile);
+ }
+ flowFileBatch.forEach(session::remove);
+ flowFileBatch.clear();
+ }
+
+ private void initWriter(RecordSchema schema) {
+ if (recordWriter != null) {
+ try {
+ recordWriter.finishRecordSet();
+ recordWriter.close();
+
+ updateFlowFileAttributes();
+
+ if (commitImmediately && flowFileBatch.size() == flowFilesPerBatch) {
+ session.transfer(flowFileBatch, REL_SUCCESS);
+
+ if (parentFlowFile != null) {
+ session.transfer(parentFlowFile, REL_ORIGINAL);
+ parentFlowFile = null;
+ }
+
+ session.commitAsync();
+ flowFileBatch.clear();
+ }
+ } catch (Exception ex) {
+ closeQuietly(recordWriter);
+ removeUnfinishedFlowFiles();
+
+ throw new ProcessException("Error closing record writer", ex);
+ }
+ }
+
+ final FlowFile newFlowFile = session.create();
+ final OutputStream out = session.write(newFlowFile);
+ try {
+ recordWriter = writerFactory.createWriter(logger, schema, out, newFlowFile);
+ recordWriter.beginRecordSet();
+ currentFlowFile = newFlowFile;
+ } catch (Exception ex) {
+ closeQuietly(out);
+
+ session.remove(newFlowFile);
+ flowFileBatch.forEach(session::remove);
+ flowFileBatch.clear();
+
+ throw new ProcessException("Error creating record writer", ex);
+ }
+ }
+
+ @Override
+ public void receive(long rowNumber,
+ org.apache.nifi.serialization.record.Record result, boolean hasMore) {
+ final boolean shouldRotateWriter = recordWriter == null
+ || (rowsPerFlowFile > 0 && ++currentIndex % rowsPerFlowFile == 0);
+
+ if (shouldRotateWriter) {
+ initWriter(result.getSchema());
+ }
+
+ try {
+ recordWriter.write(result);
+
+ if (!hasMore) {
+ recordWriter.finishRecordSet();
+ recordWriter.close();
+
+ updateFlowFileAttributes();
+ finalizeFragmentCounts();
+
+ if (parentFlowFile != null) {
+ session.transfer(parentFlowFile, REL_ORIGINAL);
+ parentFlowFile = null;
+ }
+
+ session.transfer(flowFileBatch, REL_SUCCESS);
+ flowFileBatch.clear();
+ }
+
+ } catch (Exception ex) {
+ closeQuietly(recordWriter);
+ removeUnfinishedFlowFiles();
+
+ throw new ProcessException("Error writing record", ex);
+ }
+ }
+
+ @Override
+ public void clear() {
+ closeQuietly(recordWriter);
+ removeUnfinishedFlowFiles();
+ }
+
+ @Override
+ public boolean hasSentOriginal() {
+ return parentFlowFile == null;
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/ExecuteCQLQueryRecord.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/ExecuteCQLQueryRecord.java
new file mode 100644
index 000000000000..ccb751c377b3
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/ExecuteCQLQueryRecord.java
@@ -0,0 +1,402 @@
+/*
+ * 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 org.apache.nifi.processors.cql;
+
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.SystemResource;
+import org.apache.nifi.annotation.behavior.SystemResourceConsideration;
+import org.apache.nifi.annotation.behavior.WritesAttribute;
+import org.apache.nifi.annotation.behavior.WritesAttributes;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.SeeAlso;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.documentation.UseCase;
+import org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.serialization.RecordSetWriterFactory;
+import org.apache.nifi.service.cql.api.exception.QueryFailureException;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.service.cql.api.service.QueryOverrides;
+import org.apache.nifi.util.StopWatch;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Tags({"cassandra", "scylladb", "cql", "select"})
+@InputRequirement(InputRequirement.Requirement.INPUT_ALLOWED)
+@CapabilityDescription("Execute provided Cassandra Query Language (CQL) select query on a data store that supports CQL (Cassandra or ScyllaDB primarily). Using a" +
+ " configured record writer service, it will convert result rows into any output format supported by NiFi's record API.")
+@WritesAttributes({
+ @WritesAttribute(attribute = "fragment.identifier", description = "If 'Max Rows Per Flow File' is set then all FlowFiles from the same query result set "
+ + "will have the same value for the fragment.identifier attribute. This can then be used to correlate the results."),
+ @WritesAttribute(attribute = "fragment.count", description = "If 'Max Rows Per Flow File' is set then this is the total number of "
+ + "FlowFiles produced by a single ResultSet. This can be used in conjunction with the "
+ + "fragment.identifier attribute in order to know how many FlowFiles belonged to the same incoming ResultSet. If Output Batch Size is set, then this "
+ + "attribute will not be populated."),
+ @WritesAttribute(attribute = "fragment.index", description = "If 'Max Rows Per Flow File' is set then the position of this FlowFile in the list of "
+ + "outgoing FlowFiles that were all derived from the same result set FlowFile. This can be "
+ + "used in conjunction with the fragment.identifier attribute to know which FlowFiles originated from the same query result set and in what order "
+ + "FlowFiles were produced")
+})
+@DynamicProperty(name = "cql.arg.", value = "The value to bind to that bind marker",
+ expressionLanguageScope = ExpressionLanguageScope.FLOWFILE_ATTRIBUTES,
+ description = "Supplies the value for one '?' bind marker in the CQL select query, where is the marker's "
+ + "1-based position in the query - cql.arg.1 for the first, cql.arg.2 for the second, and so on. Positions "
+ + "must run consecutively from 1, and the number of parameters must match the number of bind markers in the "
+ + "query. Each value is sent to the cluster as data and is never parsed as CQL, so this is the safe way to "
+ + "build a query around a value taken from a FlowFile attribute. See 'Additional Details'.")
+@SystemResourceConsideration(resource = SystemResource.MEMORY,
+ description = "With the default 'Max Rows Per Flow File' of 0, an entire result set is written to a single "
+ + "FlowFile; with the default 'Output Batch Size' of 0, every output FlowFile is held in the session "
+ + "until the whole result set has been read. Set both when querying large tables.")
+@SeeAlso(
+ value = {PutCQLRecord.class},
+ // The session provider services cannot be referenced by class: this module is barred from depending on either
+ // of them - and so on the database drivers they carry - by the ban-database-client-dependencies enforcer rule.
+ classNames = {
+ "org.apache.nifi.service.cassandra.CassandraCQLExecutionService",
+ "org.apache.nifi.service.scylladb.ScyllaDBCQLExecutionService"
+ })
+@UseCase(
+ description = "Run a fixed CQL query on a schedule and emit the results as records.",
+ inputRequirement = InputRequirement.Requirement.INPUT_FORBIDDEN,
+ keywords = {"cassandra", "scylladb", "cql", "select", "query", "source"},
+ notes = "A scheduled processor runs on every node of a NiFi cluster, so the query is executed once per node and "
+ + "each node emits its own copy of the result. Set the processor's Execution to 'Primary node only' if "
+ + "a single copy is wanted. Note also that every run re-executes the whole query: this processor keeps "
+ + "no state, so there is no built-in way to fetch only rows that are new since the last run.",
+ configuration = """
+ Give the processor no incoming connection and schedule it on a timer.
+
+ Set "CQL select query" to the query to run and "Result Set Output Writer" to a record writer for the \
+ desired output format.
+
+ Set "Max Rows Per Flow File" to split a large result set across several FlowFiles, and "Output Batch \
+ Size" to release those FlowFiles downstream as the result set is read rather than all at once when it \
+ completes.
+ """)
+@UseCase(
+ description = "Query a table using values taken from an incoming FlowFile, without exposing the query to CQL injection.",
+ inputRequirement = InputRequirement.Requirement.INPUT_REQUIRED,
+ keywords = {"cassandra", "scylladb", "cql", "select", "query", "parameter", "bind"},
+ notes = "Anything interpolated into the query text with Expression Language is parsed as CQL, so a query built "
+ + "that way from FlowFile attributes is injectable. Bind markers are not: each cql.arg. value "
+ + "is sent to the cluster as data. Prefer bind markers whenever a value originates outside the flow's "
+ + "own configuration.",
+ configuration = """
+ Write the query with '?' bind markers in place of the values, for example: \
+ SELECT * FROM my_keyspace.events WHERE id = ?
+
+ Add one dynamic property per marker, named cql.arg.1, cql.arg.2 and so on in the order the markers \
+ appear, with each value supplied by Expression Language against the FlowFile's attributes. The \
+ positions must run consecutively from 1, and the count must match the number of markers, or the \
+ processor is invalid.
+
+ The incoming FlowFile is routed to 'original' once the query completes; result records leave via \
+ 'success'.
+ """)
+public class ExecuteCQLQueryRecord extends AbstractCQLProcessor {
+
+ /**
+ * Matches the dynamic property name for a positional query parameter - {@code cql.arg.1}, {@code cql.arg.2},
+ * and so on - capturing the 1-based position of the bind marker the property supplies a value for.
+ */
+ private static final Pattern QUERY_PARAMETER_PATTERN = Pattern.compile("^cql\\.arg\\.(?[1-9]\\d*)$");
+
+ static final PropertyDescriptor CQL_SELECT_QUERY = new PropertyDescriptor.Builder()
+ .name("CQL select query")
+ .description("""
+ CQL select query. Values that come from outside the flow's own configuration - a FlowFile \
+ attribute, for example - should be supplied as '?' bind markers with matching cql.arg. \
+ dynamic properties rather than interpolated into this query text, since anything interpolated here \
+ is parsed as CQL.""")
+ .required(true)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .build();
+
+ static final PropertyDescriptor QUERY_TIMEOUT = new PropertyDescriptor.Builder()
+ .name("Max Wait Time")
+ .description("""
+ The maximum amount of time allowed for this query to run, overriding the Read Timeout configured on the \
+ connection service for this query only. Must be of format where is a \
+ non-negative integer and TimeUnit is a supported Time Unit, such as: nanos, millis, secs, mins, hrs, days. \
+ If not set, the connection service's configured Read Timeout is used.""")
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+ .build();
+
+ static final PropertyDescriptor FETCH_SIZE = new PropertyDescriptor.Builder()
+ .name("Fetch Size")
+ .description("""
+ The number of result rows to be fetched from the result set at a time, overriding the Fetch Size \
+ configured on the connection service for this query only. If not set, the connection service's \
+ configured Fetch Size is used.""")
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.INTEGER_VALIDATOR)
+ .build();
+
+ static final PropertyDescriptor MAX_ROWS_PER_FLOW_FILE = new PropertyDescriptor.Builder()
+ .name("Max Rows Per Flow File")
+ .description("""
+ The maximum number of result rows that will be included in a single FlowFile. This will allow you to break up very large \
+ result sets into multiple FlowFiles. If the value specified is zero, then all rows are returned in a single FlowFile.""")
+ .defaultValue("0")
+ .required(true)
+ .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+ .addValidator(StandardValidators.INTEGER_VALIDATOR)
+ .build();
+
+ static final PropertyDescriptor OUTPUT_BATCH_SIZE = new PropertyDescriptor.Builder()
+ .name("Output Batch Size")
+ .description("""
+ The number of output FlowFiles to queue before committing the process session. When set to zero, the session will be committed when all result set rows \
+ have been processed and the output FlowFiles are ready for transfer to the downstream relationship. For large result sets, this can cause a large burst of FlowFiles \
+ to be transferred at the end of processor execution. If this property is set, then when the specified number of FlowFiles are ready for transfer, then the session will \
+ be committed, thus releasing the FlowFiles to the downstream relationship. NOTE: The maxvalue.* and fragment.count attributes will not be set on FlowFiles when this \
+ property is set.""")
+ .defaultValue("0")
+ .required(true)
+ .addValidator(StandardValidators.NON_NEGATIVE_INTEGER_VALIDATOR)
+ .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+ .build();
+
+ static final PropertyDescriptor OUTPUT_WRITER = new PropertyDescriptor.Builder()
+ .name("Result Set Output Writer")
+ .identifiesControllerService(RecordSetWriterFactory.class)
+ .required(true)
+ .description("The controller service to use for writing the results to a flowfile")
+ .build();
+
+ static final Relationship REL_ORIGINAL = new Relationship.Builder()
+ .autoTerminateDefault(true)
+ .name("original")
+ .description("The incoming FlowFile that triggered the query is routed here once every resulting "
+ + "FlowFile has been transferred to success, or immediately if the query returned no rows. "
+ + "On a failed query the incoming FlowFile goes to failure or retry instead, never here.")
+ .build();
+
+ static final List PROPERTY_DESCRIPTORS = List.of(
+ CONNECTION_PROVIDER_SERVICE,
+ OUTPUT_WRITER,
+ CQL_SELECT_QUERY,
+ FETCH_SIZE,
+ QUERY_TIMEOUT,
+ MAX_ROWS_PER_FLOW_FILE,
+ OUTPUT_BATCH_SIZE
+ );
+
+ static final Set RELATIONSHIPS = Set.of(REL_SUCCESS, REL_ORIGINAL, REL_FAILURE, REL_RETRY);
+
+ @Override
+ public Set getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ @Override
+ public final List getSupportedPropertyDescriptors() {
+ return PROPERTY_DESCRIPTORS;
+ }
+
+ @Override
+ protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) {
+ final Matcher matcher = QUERY_PARAMETER_PATTERN.matcher(propertyDescriptorName);
+
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException(String.format(
+ "'%s' is not a valid query parameter name; positional parameters are named cql.arg.1, cql.arg.2, and so on",
+ propertyDescriptorName));
+ }
+
+ return new PropertyDescriptor.Builder()
+ .dynamic(true)
+ .name(propertyDescriptorName)
+ .description(String.format("The value bound to bind marker %s of the CQL select query.", matcher.group("position")))
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_EL_VALIDATOR)
+ .build();
+ }
+
+ /**
+ * Rejects a gap or a non-1 start in the positional parameter numbering. The parameters are bound by
+ * position, so {@code cql.arg.1} plus {@code cql.arg.3} is ambiguous rather than merely unusual - it would
+ * otherwise silently bind the second marker with the third parameter's value.
+ */
+ @Override
+ protected Collection customValidate(final ValidationContext context) {
+ final List positions = getParameterPositions(context.getProperties().keySet());
+
+ for (int index = 0; index < positions.size(); index++) {
+ final int expected = index + 1;
+
+ if (positions.get(index) != expected) {
+ return List.of(new ValidationResult.Builder()
+ .subject("Query parameters")
+ .valid(false)
+ .explanation(String.format(
+ "positional query parameters must be numbered consecutively starting at 1, but cql.arg.%d is missing",
+ expected))
+ .build());
+ }
+ }
+
+ return List.of();
+ }
+
+ private static List getParameterPositions(final Collection descriptors) {
+ return descriptors.stream()
+ .filter(PropertyDescriptor::isDynamic)
+ .map(descriptor -> QUERY_PARAMETER_PATTERN.matcher(descriptor.getName()))
+ .filter(Matcher::matches)
+ .map(matcher -> Integer.valueOf(matcher.group("position")))
+ .sorted()
+ .toList();
+ }
+
+ /**
+ * Collects the {@code cql.arg.} dynamic properties into the positional order the bind markers
+ * expect, evaluating each against {@code flowFile}'s attributes. Numbering is already known to be
+ * consecutive from 1 by {@link #customValidate(ValidationContext)}, so sorting by position is enough.
+ */
+ private List getQueryParameters(final ProcessContext context, final FlowFile flowFile) {
+ final SortedMap parametersByPosition = new TreeMap<>();
+
+ for (final PropertyDescriptor descriptor : context.getProperties().keySet()) {
+ if (!descriptor.isDynamic()) {
+ continue;
+ }
+
+ final Matcher matcher = QUERY_PARAMETER_PATTERN.matcher(descriptor.getName());
+
+ if (matcher.matches()) {
+ parametersByPosition.put(Integer.valueOf(matcher.group("position")),
+ context.getProperty(descriptor).evaluateAttributeExpressions(flowFile).getValue());
+ }
+ }
+
+ return new ArrayList<>(parametersByPosition.values());
+ }
+
+ @OnScheduled
+ @Override
+ public void onScheduled(final ProcessContext context) {
+ super.onScheduled(context);
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException {
+ FlowFile fileToProcess = null;
+
+ if (context.hasIncomingConnection()) {
+ fileToProcess = session.get();
+
+ // If we have no FlowFile, and all incoming connections are self-loops then we can continue on.
+ // However, if we have no FlowFile and we have connections coming from other Processors, then
+ // we know that we should run only if we have a FlowFile.
+ if (fileToProcess == null && context.hasNonLoopConnection()) {
+ return;
+ }
+ }
+
+ final ComponentLog logger = getLogger();
+ final String selectQuery = context.getProperty(CQL_SELECT_QUERY).evaluateAttributeExpressions(fileToProcess).getValue();
+ final long maxRowsPerFlowFile = context.getProperty(MAX_ROWS_PER_FLOW_FILE).evaluateAttributeExpressions().asInteger();
+ final long outputBatchSize = context.getProperty(OUTPUT_BATCH_SIZE).evaluateAttributeExpressions().asInteger();
+
+ final PropertyValue fetchSizeProperty = context.getProperty(FETCH_SIZE).evaluateAttributeExpressions(fileToProcess);
+ final Integer fetchSizeOverride = fetchSizeProperty.isSet() ? fetchSizeProperty.asInteger() : null;
+
+ final PropertyValue queryTimeoutProperty = context.getProperty(QUERY_TIMEOUT).evaluateAttributeExpressions(fileToProcess);
+ final Duration queryTimeoutOverride = queryTimeoutProperty.isSet() ? queryTimeoutProperty.asDuration() : null;
+
+ final QueryOverrides queryOverrides = new QueryOverrides(fetchSizeOverride, queryTimeoutOverride);
+ final List queryParameters = getQueryParameters(context, fileToProcess);
+
+ final StopWatch stopWatch = new StopWatch(true);
+
+ final RecordSetWriterFactory writerFactory = context.getProperty(OUTPUT_WRITER).asControllerService(RecordSetWriterFactory.class);
+ final CQLExecutionService cqlExecutionService = context.getProperty(CONNECTION_PROVIDER_SERVICE)
+ .asControllerService(CQLExecutionService.class);
+
+ final ExecuteCQLQueryCallback callback = new ExecuteCQLQueryCallback(fileToProcess, writerFactory, session,
+ getLogger(), maxRowsPerFlowFile, outputBatchSize);
+
+ try {
+ stopWatch.start();
+
+ cqlExecutionService.query(selectQuery, queryParameters, callback, queryOverrides);
+
+ if (callback.isEmpty() && fileToProcess != null) {
+ session.transfer(fileToProcess, REL_ORIGINAL);
+ }
+
+ stopWatch.stop();
+
+ getLogger().debug("The query took {} seconds.", stopWatch.getDuration(TimeUnit.SECONDS));
+ } catch (final QueryFailureException qee) {
+ //The logger is called in the client service
+ if (context.hasIncomingConnection()) {
+ if (fileToProcess == null || callback.hasSentOriginal()) {
+ fileToProcess = session.create();
+ }
+ fileToProcess = session.penalize(fileToProcess);
+ session.transfer(fileToProcess, REL_RETRY);
+ } else {
+ context.yield();
+ }
+ } catch (final ProcessException e) {
+ if (context.hasIncomingConnection()) {
+ logger.error(String.format("Unable to execute CQL select query %s for %s routing to failure",
+ selectQuery, fileToProcess), e);
+ if (fileToProcess == null || callback.hasSentOriginal()) {
+ fileToProcess = session.create();
+ }
+
+ fileToProcess = session.penalize(fileToProcess);
+ session.transfer(fileToProcess, REL_FAILURE);
+
+ } else {
+ logger.error(String.format("Unable to execute CQL select query %s",
+ selectQuery), e);
+ context.yield();
+ }
+ }
+ }
+}
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/PutCQLRecord.java b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/PutCQLRecord.java
new file mode 100644
index 000000000000..e10dff70e57c
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cql-processors/src/main/java/org/apache/nifi/processors/cql/PutCQLRecord.java
@@ -0,0 +1,624 @@
+/*
+ * 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 org.apache.nifi.processors.cql;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.annotation.behavior.DynamicProperty;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.ReadsAttribute;
+import org.apache.nifi.annotation.behavior.ReadsAttributes;
+import org.apache.nifi.annotation.behavior.SupportsBatching;
+import org.apache.nifi.annotation.behavior.SystemResource;
+import org.apache.nifi.annotation.behavior.SystemResourceConsideration;
+import org.apache.nifi.annotation.behavior.WritesAttribute;
+import org.apache.nifi.annotation.behavior.WritesAttributes;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.SeeAlso;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.documentation.UseCase;
+import org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.annotation.lifecycle.OnShutdown;
+import org.apache.nifi.annotation.lifecycle.OnUnscheduled;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.expression.ExpressionLanguageScope;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.processors.cql.constants.BatchStatementType;
+import org.apache.nifi.processors.cql.constants.StatementType;
+import org.apache.nifi.processors.cql.constants.UpdateType;
+import org.apache.nifi.record.path.RecordPath;
+import org.apache.nifi.record.path.util.RecordPathCache;
+import org.apache.nifi.record.path.validation.RecordPathValidator;
+import org.apache.nifi.serialization.RecordReader;
+import org.apache.nifi.serialization.RecordReaderFactory;
+import org.apache.nifi.serialization.record.Record;
+import org.apache.nifi.serialization.record.RecordSchema;
+import org.apache.nifi.service.cql.api.constants.CqlBatchType;
+import org.apache.nifi.service.cql.api.constants.UpdateMethod;
+import org.apache.nifi.service.cql.api.exception.QueryFailureException;
+import org.apache.nifi.service.cql.api.metadata.PrimaryKeyIdentifier;
+import org.apache.nifi.service.cql.api.metadata.QualifiedTableName;
+import org.apache.nifi.service.cql.api.service.CQLExecutionService;
+import org.apache.nifi.service.cql.api.service.WriteOverrides;
+import org.apache.nifi.util.StopWatch;
+
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static java.lang.String.format;
+import static org.apache.nifi.processors.cql.constants.BatchStatementType.BATCH_STATEMENT_TYPE_USE_ATTR_TYPE;
+import static org.apache.nifi.processors.cql.constants.BatchStatementType.COUNTER_TYPE;
+import static org.apache.nifi.processors.cql.constants.BatchStatementType.UNLOGGED_TYPE;
+import static org.apache.nifi.processors.cql.constants.StatementType.INSERT_TYPE;
+import static org.apache.nifi.processors.cql.constants.StatementType.STATEMENT_TYPE_USE_ATTR_TYPE;
+import static org.apache.nifi.processors.cql.constants.StatementType.UPDATE_TYPE;
+import static org.apache.nifi.processors.cql.constants.UpdateType.DECR_TYPE;
+import static org.apache.nifi.processors.cql.constants.UpdateType.INCR_TYPE;
+
+@Tags({"cassandra", "scylladb", "cql", "put", "insert", "update", "set", "record"})
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@CapabilityDescription("This is a record aware processor that reads the content of the incoming FlowFile as individual records using the " +
+ "configured 'Record Reader' and writes them to a data store that supports CQL (Cassandra or ScyllaDB primarily), as inserts or " +
+ "updates, individually or in batches.")
+@ReadsAttributes({
+ @ReadsAttribute(attribute = "cql.statement.type", description = "If 'Use cql.statement.type Attribute' is selected for the Statement " +
+ "Type property, the value of the cql.statement.type Attribute will be used to determine which type of statement (UPDATE, INSERT) " +
+ "will be generated and executed"),
+ @ReadsAttribute(attribute = "cql.update.method", description = "If 'Use cql.update.method Attribute' is selected for the Update " +
+ "Method property, the value of the cql.update.method Attribute will be used to determine which operation (Set, Increment, Decrement) " +
+ "will be used to generate and execute the Update statement. Ignored if the Statement Type property is not set to UPDATE"),
+ @ReadsAttribute(attribute = "cql.batch.statement.type", description = "If 'Use cql.batch.statement.type Attribute' is selected for the Batch " +
+ "Statement Type property, the value of the cql.batch.statement.type Attribute will be used to determine which type of batch statement " +
+ "(LOGGED, UNLOGGED, COUNTER) will be generated and executed")
+})
+@WritesAttributes({
+ @WritesAttribute(attribute = "cql.records.written", description = "On failure or retry, the number of records that were already successfully "
+ + "written to Cassandra/ScyllaDB before the error occurred. Since records are written in batches as they're read, earlier batches "
+ + "may have already been committed even though the FlowFile as a whole did not succeed.")
+})
+@DynamicProperty(name = "..", value = "A RecordPath expression",
+ expressionLanguageScope = ExpressionLanguageScope.NONE,
+ description = "Overrides how the named primary key column's value is resolved for records written to the given "
+ + "keyspace-qualified table, in place of the default behavior of matching a record field with the same "
+ + "name as the column. The RecordPath is evaluated once per record and must resolve to exactly one "
+ + "value; zero or more than one is a configuration error for that record. See 'Additional Details' for "
+ + "the full name format and examples, including how to supply a valid version-1 (time-based) UUID for "
+ + "a timeuuid primary key column.")
+@SupportsBatching
+@SystemResourceConsideration(resource = SystemResource.MEMORY,
+ description = "Up to 'Batch size' records are held in memory at once, per concurrent task, in their parsed form "
+ + "rather than as the FlowFile's serialized bytes. Raising Batch size or the number of concurrent tasks "
+ + "raises heap use proportionally.")
+@SeeAlso(
+ value = {ExecuteCQLQueryRecord.class},
+ // The session provider services cannot be referenced by class: this module is barred from depending on either
+ // of them - and so on the database drivers they carry - by the ban-database-client-dependencies enforcer rule.
+ classNames = {
+ "org.apache.nifi.service.cassandra.CassandraCQLExecutionService",
+ "org.apache.nifi.service.scylladb.ScyllaDBCQLExecutionService"
+ })
+@UseCase(
+ description = "Insert records from a FlowFile into a Cassandra or ScyllaDB table.",
+ keywords = {"cassandra", "scylladb", "cql", "insert", "record"},
+ configuration = """
+ Configure a Record Reader appropriate to the incoming data and point "Cassandra Connection Provider" at \
+ the session provider service for the cluster.
+
+ Set "Table name" to the target table, either as . or as an unqualified if the \
+ connection service already names a keyspace.
+
+ Leave "Statement Type" at INSERT. Each record field is matched to the column of the same name; use a \
+ .. dynamic property to resolve a primary key column from somewhere else in the \
+ record instead.
+
+ "Batch size" controls how many records are grouped into a single batch statement. It is a throughput \
+ and heap trade-off, not a correctness one - a FlowFile larger than the batch size is written across \
+ several batches.
+ """)
+@UseCase(
+ description = "Update existing rows in a Cassandra or ScyllaDB table from the records in a FlowFile.",
+ keywords = {"cassandra", "scylladb", "cql", "update", "record"},
+ notes = "Cassandra and ScyllaDB do not distinguish an update from an insert: an UPDATE against a primary key "
+ + "that does not exist creates the row. Statement Type UPDATE differs from INSERT in that it writes "
+ + "only the columns present in the record, rather than replacing the whole row.",
+ configuration = """
+ Set "Statement Type" to UPDATE and list the primary key columns in "Update Keys" as a comma-separated \
+ list. Both are required together - an UPDATE with no update keys fails the FlowFile at runtime rather \
+ than failing validation, because the statement type may itself come from a FlowFile attribute.
+
+ Leave "Update Method" at SET to assign the record's values to the columns.
+
+ "Time To Live" and "Timestamp Field" both apply to SET-method updates. Setting "Timestamp Field" to a \
+ record field holding a stable timestamp makes reprocessing the same record a true no-op rather than a \
+ write that races whatever else has touched the row since.
+ """)
+@UseCase(
+ description = "Increment or decrement counter columns in a Cassandra or ScyllaDB counter table.",
+ keywords = {"cassandra", "scylladb", "cql", "counter", "increment", "decrement"},
+ notes = "Counter mutations are the one CQL write that is not idempotent: applying the same increment twice "
+ + "counts twice. Leave Run Duration at 0 for counter flows, since a longer Run Duration allows the "
+ + "framework to batch session commits, and a rolled-back batch is reprocessed from the queue. For the "
+ + "same reason, prefer counter flows that can tolerate at-least-once delivery.",
+ configuration = """
+ Set "Statement Type" to UPDATE, list the counter table's primary key columns in "Update Keys", and set \
+ "Update Method" to Increment or Decrement. Each record field matching a counter column supplies the \
+ amount to add or subtract.
+
+ Set "Batch Statement Type" to COUNTER, which is the type Cassandra and ScyllaDB require for counter \
+ mutations. UNLOGGED is also accepted; LOGGED is rejected.
+
+ Statement Type INSERT cannot be used against a counter table and is rejected.
+
+ "Time To Live" and "Timestamp Field" are ignored here - neither is supported on counter columns.
+ """)
+public class PutCQLRecord extends AbstractCQLProcessor {
+ static final String STATEMENT_TYPE_ATTRIBUTE = "cql.statement.type";
+
+ static final String UPDATE_METHOD_ATTRIBUTE = "cql.update.method";
+
+ static final String BATCH_STATEMENT_TYPE_ATTRIBUTE = "cql.batch.statement.type";
+
+ static final String RECORDS_WRITTEN_ATTRIBUTE = "cql.records.written";
+
+ static final PropertyDescriptor RECORD_READER_FACTORY = new PropertyDescriptor.Builder()
+ .name("Record Reader")
+ .description("""
+ Specifies the type of Record Reader controller service to use for parsing the incoming data \
+ and determining the schema""")
+ .identifiesControllerService(RecordReaderFactory.class)
+ .required(true)
+ .build();
+
+ static final PropertyDescriptor STATEMENT_TYPE = new PropertyDescriptor.Builder()
+ .name("Statement Type")
+ .description("Specifies the type of CQL Statement to generate.")
+ .required(true)
+ .defaultValue(INSERT_TYPE.getValue())
+ .allowableValues(StatementType.class)
+ .build();
+
+ static final PropertyDescriptor UPDATE_METHOD = new PropertyDescriptor.Builder()
+ .name("Update Method")
+ .description("""
+ Specifies the method to use to SET the values. This property is used if the Statement Type is \
+ UPDATE and ignored otherwise.""")
+ .required(false)
+ .defaultValue(UpdateType.SET_TYPE.getValue())
+ .allowableValues(UpdateType.class)
+ .build();
+
+ static final PropertyDescriptor UPDATE_KEYS = new PropertyDescriptor.Builder()
+ .name("Update Keys")
+ .description("""
+ A comma-separated list of column names that uniquely identifies a row in the database for UPDATE statements. \
+ If the Statement Type is UPDATE and this property is not set, the conversion to CQL will fail. \
+ This property is ignored if the Statement Type is not UPDATE.""")
+ .addValidator(StandardValidators.createListValidator(true, false, StandardValidators.NON_EMPTY_VALIDATOR))
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .build();
+
+ static final PropertyDescriptor TABLE = new PropertyDescriptor.Builder()
+ .name("Table name")
+ .description("""
+ The name of the Cassandra table to which the records have to be written. This can be expressed \
+ as either a raw table name or a qualified table name (ex. .). Due to the dynamic \
+ nature of this property, it will be validated at runtime by this processor and raise an error if \
+ it is neither nor . when the value is retrieved.""")
+ .required(true)
+ .addValidator(StandardValidators.NON_EMPTY_EL_VALIDATOR)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .build();
+
+ static final PropertyDescriptor BATCH_SIZE = new PropertyDescriptor.Builder()
+ .name("Batch size")
+ .description("Specifies the number of 'Insert statements' to be grouped together to execute as a batch (BatchStatement)")
+ .defaultValue("100")
+ .addValidator(StandardValidators.POSITIVE_INTEGER_VALIDATOR)
+ .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+ .required(true)
+ .build();
+
+ static final PropertyDescriptor BATCH_STATEMENT_TYPE = new PropertyDescriptor.Builder()
+ .name("Batch Statement Type")
+ .description("Specifies the type of 'Batch Statement' to be used.")
+ .allowableValues(BatchStatementType.class)
+ .defaultValue(UNLOGGED_TYPE.getValue())
+ .required(false)
+ .build();
+
+ static final PropertyDescriptor TTL = new PropertyDescriptor.Builder()
+ .name("Time To Live")
+ .description("""
+ Overrides the connection service's configured Default Time To Live (TTL) for records written by this processor. \
+ Applies to INSERT statements and to UPDATE statements using the SET method; ignored for Increment/Decrement updates, \
+ since Cassandra/ScyllaDB do not support a TTL on counter columns. If not set, the connection service's configured \
+ default (if any) is used.""")
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
+ .build();
+
+ static final PropertyDescriptor TIMESTAMP_FIELD = new PropertyDescriptor.Builder()
+ .name("Timestamp Field")
+ .description("""
+ The name of a field in each record whose value supplies the CQL write timestamp for that record's INSERT or \
+ SET-method UPDATE statement, instead of the time the statement executes. Useful for safe retries/reprocessing: \
+ resubmitting the same record with the same timestamp is a true no-op rather than a write that could win a \
+ last-write-wins race against different data written to the same row in the meantime. Ignored for Increment/Decrement \
+ updates, since Cassandra/ScyllaDB do not support a custom write timestamp on counter columns. If not set, the current \
+ time is used, as usual.""")
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ private static final List PROPERTY_DESCRIPTORS = Collections.unmodifiableList(Arrays.asList(
+ CONNECTION_PROVIDER_SERVICE, TABLE, STATEMENT_TYPE, UPDATE_KEYS, UPDATE_METHOD,
+ RECORD_READER_FACTORY, BATCH_SIZE, BATCH_STATEMENT_TYPE, TTL, TIMESTAMP_FIELD));
+
+ private static final Set RELATIONSHIPS = Collections.unmodifiableSet(
+ new HashSet<>(Arrays.asList(REL_SUCCESS, REL_FAILURE, REL_RETRY)));
+
+ private static final Pattern QUALIFIED_TABLE_PATTERN = Pattern.compile(
+ "^(?[a-zA-Z][a-zA-Z0-9_]{0,47})\\.(?[a-zA-Z][a-zA-Z0-9_]{0,47})\\.(?