diff --git a/.gitignore b/.gitignore
index e299e15e43c4..fd80f9edf700 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
target
bin
+dependency-reduced-pom.xml
.project
.settings
.classpath
diff --git a/nifi-assembly/NOTICE b/nifi-assembly/NOTICE
index 3c66982fdc92..455f47323cbd 100644
--- a/nifi-assembly/NOTICE
+++ b/nifi-assembly/NOTICE
@@ -1997,6 +1997,11 @@ The following binary components are provided under the Apache Software License v
the terms of a BSD style license.
The original software and related information is available
at http://www.jcraft.com/jsch/.
+
+ (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) bytebuffer-collections
The following NOTICE information applies:
bytebuffer-collections
diff --git a/nifi-assembly/pom.xml b/nifi-assembly/pom.xml
index e3cdf316ca57..b69e8b698c54 100644
--- a/nifi-assembly/pom.xml
+++ b/nifi-assembly/pom.xml
@@ -1202,5 +1202,44 @@ language governing permissions and limitations under the License. -->
false
+
+ include-cql
+
+
+ false
+
+ allProfiles
+
+
+
+
+ org.apache.nifi
+ nifi-cql-nar
+ 2.12.0-SNAPSHOT
+ nar
+
+
+ org.apache.nifi
+ nifi-cql-services-api-nar
+ 2.12.0-SNAPSHOT
+ nar
+
+
+ org.apache.nifi
+ nifi-cassandra-session-provider-service-nar
+ 2.12.0-SNAPSHOT
+ nar
+
+
+ org.apache.nifi
+ nifi-scylladb-session-provider-service-nar
+ 2.12.0-SNAPSHOT
+ nar
+
+
+
diff --git a/nifi-code-coverage/pom.xml b/nifi-code-coverage/pom.xml
index 1be8e671e662..ab7cd13d85e4 100644
--- a/nifi-code-coverage/pom.xml
+++ b/nifi-code-coverage/pom.xml
@@ -695,6 +695,21 @@
nifi-box-services-api2.12.0-SNAPSHOT
+
+ org.apache.nifi
+ nifi-cql-processors
+ 2.12.0-SNAPSHOT
+
+
+ org.apache.nifi
+ nifi-cassandra-session-provider-service
+ 2.12.0-SNAPSHOT
+
+
+ org.apache.nifi
+ nifi-cql-services-api
+ 2.12.0-SNAPSHOT
+ org.apache.nifinifi-cdc-api
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/pom.xml b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/pom.xml
new file mode 100644
index 000000000000..96897195adb2
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/pom.xml
@@ -0,0 +1,51 @@
+
+
+
+
+ nifi-cql-bundle
+ org.apache.nifi
+ 2.12.0-SNAPSHOT
+
+ 4.0.0
+
+ nifi-cassandra-session-provider-service-nar
+ nar
+
+
+
+
+
+ com.google.guava
+ guava
+ provided
+
+
+
+
+
+
+ org.apache.nifi
+ nifi-cql-services-api-nar
+ 2.12.0-SNAPSHOT
+ nar
+
+
+ org.apache.nifi
+ nifi-cassandra-session-provider-service
+ 2.12.0-SNAPSHOT
+
+
+
\ No newline at end of file
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/src/main/resources/META-INF/LICENSE b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/src/main/resources/META-INF/LICENSE
new file mode 100644
index 000000000000..c567ce25dfd7
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/src/main/resources/META-INF/LICENSE
@@ -0,0 +1,352 @@
+
+ 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.
+
+This product bundles 'logback' which is dual-licensed under the EPL v1.0
+and the LGPL 2.1.
+
+ Logback: the reliable, generic, fast and flexible logging framework.
+
+ Copyright (C) 1999-2017, QOS.ch. All rights reserved.
+
+ This program and the accompanying materials are dual-licensed under
+ either the terms of the Eclipse Public License v1.0 as published by
+ the Eclipse Foundation or (per the licensee's choosing) under the
+ terms of the GNU Lesser General Public License version 2.1 as
+ published by the Free Software Foundation.
+
+The binary distribution of this product bundles 'ANTLR 3' which is available
+under a "3-clause BSD" license. For details see http://www.antlr.org/license.html
+
+ Copyright (c) 2012 Terence Parr and Sam Harwell
+ All rights reserved.
+ Redistribution and use in source and binary forms, with or without modification, are permitted
+ provided that the following conditions are met:
+
+ Redistributions of source code must retain the above copyright notice, this list of
+ conditions and the following disclaimer.
+ 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.
+
+ Neither the name of the author 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-cassandra-session-provider-service-nar/src/main/resources/META-INF/NOTICE b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/src/main/resources/META-INF/NOTICE
new file mode 100644
index 000000000000..8e2e0dcfb324
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service-nar/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,313 @@
+nifi-cassandra-services-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) 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 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) JSON-SMART
+ The following NOTICE information applies:
+ Copyright 2011 JSON-SMART 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
+
+************************
+Eclipse Public License 1.0
+************************
+
+The following binary components are provided under the Eclipse Public License 1.0. See project link for details.
+
+ (EPL 2.0)(GPL 2)(LGPL 2.1) JNR Posix ( jnr.posix ) https://github.com/jnr/jnr-posix/blob/master/LICENSE.txt
+ (EPL 1.0)(LGPL 2.1) Logback Classic (ch.qos.logback:logback-classic:jar:1.2.6 - http://logback.qos.ch/)
+ (EPL 1.0)(LGPL 2.1) Logback Core (ch.qos.logback:logback-core:jar:1.2.6 - http://logback.qos.ch/)
+
+************************
+Test-Only Resources
+************************
+
+The following are not distributed as part of this module's binary (.nar) build, but are present in the
+source tree under nifi-cassandra-session-provider-service/src/test/resources/ and are copied, with
+explanatory comments trimmed, from the stock cassandra.yaml shipped in the corresponding Apache Cassandra
+release:
+
+ - cassandra-base-config/cassandra.yaml (from Apache Cassandra 5.0)
+ - cassandra-auth-config-4.1/cassandra.yaml (from Apache Cassandra 4.1)
+ - cassandra-auth-config-3.11/cassandra.yaml (from Apache Cassandra 3.11)
+
+ (ASLv2) Apache Cassandra
+ The following NOTICE information applies:
+ Apache Cassandra
+ Copyright 2009- The Apache Software Foundation
+
+ This product includes software developed by The Apache Software
+ Foundation (http://www.apache.org/).
diff --git a/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/pom.xml b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/pom.xml
new file mode 100644
index 000000000000..03ecf7d48ab3
--- /dev/null
+++ b/nifi-extension-bundles/nifi-cql-bundle/nifi-cassandra-session-provider-service/pom.xml
@@ -0,0 +1,107 @@
+
+
+
+
+ nifi-cql-bundle
+ org.apache.nifi
+ 2.12.0-SNAPSHOT
+
+ 4.0.0
+
+ nifi-cassandra-session-provider-service
+ jar
+
+
+ 4.19.3
+
+
+
+
+ org.apache.nifi
+ nifi-api
+
+
+ org.apache.nifi
+ nifi-utils
+
+
+ org.apache.nifi
+ nifi-cql-services-api
+ 2.12.0-SNAPSHOT
+ provided
+
+
+
+ org.apache.nifi
+ nifi-cql-session-provider-shared
+ 2.12.0-SNAPSHOT
+
+
+
+
+ org.apache.cassandra
+ java-driver-core
+ ${driver.version}
+
+
+
+
+ org.apache.nifi
+ nifi-ssl-context-service-api
+
+
+ org.apache.nifi
+ nifi-framework-api
+
+
+
+ org.apache.nifi
+ nifi-mock
+ test
+
+
+
+ org.testcontainers
+ testcontainers-cassandra
+ test
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+ org.apache.nifi
+ nifi-security-cert-builder
+ 2.12.0-SNAPSHOT
+ test
+
+
+ org.apache.nifi
+ nifi-ssl-context-service
+ 2.12.0-SNAPSHOT
+ test
+
+
+ org.apache.nifi
+ nifi-cql-it-common
+ 2.12.0-SNAPSHOT
+ tests
+ test
+
+
+
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/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/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/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.
+ *
+ *
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..621be99922b9
--- /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,230 @@
+/*
+ * 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 long currentRecordCount;
+ 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.currentRecordCount = 0L;
+ 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(),
+ "record.count", String.valueOf(currentRecordCount));
+
+ 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();
+ currentRecordCount = 0L;
+
+ 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);
+ this.currentRecordCount++;
+
+ 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