Merge branch '4.x' into JAVA-3065
diff --git a/integration-tests/src/test/java/com/datastax/dse/driver/api/core/auth/EmbeddedAdsRule.java b/integration-tests/src/test/java/com/datastax/dse/driver/api/core/auth/EmbeddedAdsRule.java
index cbec842..0903eb9 100644
--- a/integration-tests/src/test/java/com/datastax/dse/driver/api/core/auth/EmbeddedAdsRule.java
+++ b/integration-tests/src/test/java/com/datastax/dse/driver/api/core/auth/EmbeddedAdsRule.java
@@ -20,12 +20,14 @@
 import com.datastax.oss.driver.api.core.CqlSession;
 import com.datastax.oss.driver.api.core.Version;
 import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
-import com.datastax.oss.driver.api.testinfra.DseRequirement;
 import com.datastax.oss.driver.api.testinfra.ccm.CcmBridge;
 import com.datastax.oss.driver.api.testinfra.ccm.CustomCcmRule;
+import com.datastax.oss.driver.api.testinfra.requirement.BackendType;
+import com.datastax.oss.driver.api.testinfra.requirement.VersionRequirement;
 import com.datastax.oss.driver.api.testinfra.session.SessionUtils;
 import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
 import java.io.File;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import org.junit.AssumptionViolatedException;
@@ -151,50 +153,25 @@
     }
   }
 
-  private Statement buildErrorStatement(
-      Version requirement, Version actual, String description, boolean lessThan) {
-    return new Statement() {
-
-      @Override
-      public void evaluate() {
-        throw new AssumptionViolatedException(
-            String.format(
-                "Test requires %s %s %s but %s is configured.  Description: %s",
-                lessThan ? "less than" : "at least", "DSE", requirement, actual, description));
-      }
-    };
-  }
-
   @Override
   public Statement apply(Statement base, Description description) {
-    DseRequirement dseRequirement = description.getAnnotation(DseRequirement.class);
-    if (dseRequirement != null) {
-      if (!CcmBridge.DSE_ENABLEMENT) {
-        return new Statement() {
-          @Override
-          public void evaluate() {
-            throw new AssumptionViolatedException("Test Requires DSE but C* is configured.");
-          }
-        };
-      } else {
-        Version dseVersion = CcmBridge.VERSION;
-        if (!dseRequirement.min().isEmpty()) {
-          Version minVersion = Version.parse(dseRequirement.min());
-          if (minVersion.compareTo(dseVersion) > 0) {
-            return buildErrorStatement(dseVersion, dseVersion, dseRequirement.description(), false);
-          }
-        }
+    BackendType backend = CcmBridge.DSE_ENABLEMENT ? BackendType.DSE : BackendType.CASSANDRA;
+    Version version = CcmBridge.VERSION;
 
-        if (!dseRequirement.max().isEmpty()) {
-          Version maxVersion = Version.parse(dseRequirement.max());
+    Collection<VersionRequirement> requirements = VersionRequirement.fromAnnotations(description);
 
-          if (maxVersion.compareTo(dseVersion) <= 0) {
-            return buildErrorStatement(dseVersion, dseVersion, dseRequirement.description(), true);
-          }
+    if (VersionRequirement.meetsAny(requirements, backend, version)) {
+      return super.apply(base, description);
+    } else {
+      // requirements not met, throw reasoning assumption to skip test
+      return new Statement() {
+        @Override
+        public void evaluate() {
+          throw new AssumptionViolatedException(
+              VersionRequirement.buildReasonString(requirements, backend, version));
         }
-      }
+      };
     }
-    return super.apply(base, description);
   }
 
   @Override
diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/cql/PreparedStatementIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/cql/PreparedStatementIT.java
index fe2df25..c3494a6 100644
--- a/integration-tests/src/test/java/com/datastax/oss/driver/core/cql/PreparedStatementIT.java
+++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/cql/PreparedStatementIT.java
@@ -16,7 +16,7 @@
 package com.datastax.oss.driver.core.cql;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.catchThrowable;
 
 import com.codahale.metrics.Gauge;
@@ -36,6 +36,8 @@
 import com.datastax.oss.driver.api.core.type.DataTypes;
 import com.datastax.oss.driver.api.testinfra.CassandraRequirement;
 import com.datastax.oss.driver.api.testinfra.ccm.CcmRule;
+import com.datastax.oss.driver.api.testinfra.requirement.BackendRequirement;
+import com.datastax.oss.driver.api.testinfra.requirement.BackendType;
 import com.datastax.oss.driver.api.testinfra.session.SessionRule;
 import com.datastax.oss.driver.api.testinfra.session.SessionUtils;
 import com.datastax.oss.driver.categories.ParallelizableTests;
@@ -54,6 +56,7 @@
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 import junit.framework.TestCase;
+import org.assertj.core.api.AbstractThrowableAssert;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -425,13 +428,11 @@
   }
 
   /**
-   * This test relies on CASSANDRA-15252 to reproduce the error condition. If the bug gets fixed in
-   * Cassandra, we'll need to add a version restriction.
+   * This method reproduces CASSANDRA-15252 which is fixed in 3.0.26/3.11.12/4.0.2.
    *
    * @see <a href="https://issues.apache.org/jira/browse/CASSANDRA-15252">CASSANDRA-15252</a>
    */
-  @Test
-  public void should_fail_fast_if_id_changes_on_reprepare() {
+  private AbstractThrowableAssert<?, ? extends Throwable> assertableReprepareAfterIdChange() {
     try (CqlSession session = SessionUtils.newSession(ccmRule)) {
       PreparedStatement preparedStatement =
           session.prepare(
@@ -444,12 +445,42 @@
       executeDdl("DROP TABLE prepared_statement_test");
       executeDdl("CREATE TABLE prepared_statement_test (a int PRIMARY KEY, b int, c int)");
 
-      assertThatThrownBy(() -> session.execute(preparedStatement.bind(1)))
-          .isInstanceOf(IllegalStateException.class)
-          .hasMessageContaining("ID mismatch while trying to reprepare");
+      return assertThatCode(() -> session.execute(preparedStatement.bind(1)));
     }
   }
 
+  // Add version bounds to the DSE requirement if there is a version containing fix for
+  // CASSANDRA-15252
+  @BackendRequirement(
+      type = BackendType.DSE,
+      description = "No DSE version contains fix for CASSANDRA-15252")
+  @BackendRequirement(type = BackendType.CASSANDRA, minInclusive = "3.0.0", maxExclusive = "3.0.26")
+  @BackendRequirement(
+      type = BackendType.CASSANDRA,
+      minInclusive = "3.11.0",
+      maxExclusive = "3.11.12")
+  @BackendRequirement(type = BackendType.CASSANDRA, minInclusive = "4.0.0", maxExclusive = "4.0.2")
+  @Test
+  public void should_fail_fast_if_id_changes_on_reprepare() {
+    assertableReprepareAfterIdChange()
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining("ID mismatch while trying to reprepare");
+  }
+
+  @BackendRequirement(
+      type = BackendType.CASSANDRA,
+      minInclusive = "3.0.26",
+      maxExclusive = "3.11.0")
+  @BackendRequirement(
+      type = BackendType.CASSANDRA,
+      minInclusive = "3.11.12",
+      maxExclusive = "4.0.0")
+  @BackendRequirement(type = BackendType.CASSANDRA, minInclusive = "4.0.2")
+  @Test
+  public void handle_id_changes_on_reprepare() {
+    assertableReprepareAfterIdChange().doesNotThrowAnyException();
+  }
+
   private void invalidationResultSetTest(Consumer<CqlSession> createFn) {
 
     try (CqlSession session = sessionWithCacheSizeMetric()) {
diff --git a/osgi-tests/src/test/java/com/datastax/oss/driver/internal/osgi/support/CcmPaxExam.java b/osgi-tests/src/test/java/com/datastax/oss/driver/internal/osgi/support/CcmPaxExam.java
index 8697a0d..4a17006 100644
--- a/osgi-tests/src/test/java/com/datastax/oss/driver/internal/osgi/support/CcmPaxExam.java
+++ b/osgi-tests/src/test/java/com/datastax/oss/driver/internal/osgi/support/CcmPaxExam.java
@@ -18,10 +18,9 @@
 import static com.datastax.oss.driver.internal.osgi.support.CcmStagedReactor.CCM_BRIDGE;
 
 import com.datastax.oss.driver.api.core.Version;
-import com.datastax.oss.driver.api.testinfra.CassandraRequirement;
-import com.datastax.oss.driver.api.testinfra.DseRequirement;
-import java.util.Objects;
-import java.util.Optional;
+import com.datastax.oss.driver.api.testinfra.requirement.BackendType;
+import com.datastax.oss.driver.api.testinfra.requirement.VersionRequirement;
+import java.util.Collection;
 import org.junit.AssumptionViolatedException;
 import org.junit.runner.Description;
 import org.junit.runner.notification.Failure;
@@ -38,69 +37,20 @@
   @Override
   public void run(RunNotifier notifier) {
     Description description = getDescription();
-    CassandraRequirement cassandraRequirement =
-        description.getAnnotation(CassandraRequirement.class);
-    if (cassandraRequirement != null) {
-      if (!cassandraRequirement.min().isEmpty()) {
-        Version minVersion = Objects.requireNonNull(Version.parse(cassandraRequirement.min()));
-        if (minVersion.compareTo(CCM_BRIDGE.getCassandraVersion()) > 0) {
-          fireRequirementsNotMet(notifier, description, cassandraRequirement.min(), false, false);
-          return;
-        }
-      }
-      if (!cassandraRequirement.max().isEmpty()) {
-        Version maxVersion = Objects.requireNonNull(Version.parse(cassandraRequirement.max()));
-        if (maxVersion.compareTo(CCM_BRIDGE.getCassandraVersion()) <= 0) {
-          fireRequirementsNotMet(notifier, description, cassandraRequirement.max(), true, false);
-          return;
-        }
-      }
-    }
-    DseRequirement dseRequirement = description.getAnnotation(DseRequirement.class);
-    if (dseRequirement != null) {
-      Optional<Version> dseVersionOption = CCM_BRIDGE.getDseVersion();
-      if (!dseVersionOption.isPresent()) {
-        notifier.fireTestAssumptionFailed(
-            new Failure(
-                description,
-                new AssumptionViolatedException("Test Requires DSE but C* is configured.")));
-        return;
-      } else {
-        Version dseVersion = dseVersionOption.get();
-        if (!dseRequirement.min().isEmpty()) {
-          Version minVersion = Objects.requireNonNull(Version.parse(dseRequirement.min()));
-          if (minVersion.compareTo(dseVersion) > 0) {
-            fireRequirementsNotMet(notifier, description, dseRequirement.min(), false, true);
-            return;
-          }
-        }
-        if (!dseRequirement.max().isEmpty()) {
-          Version maxVersion = Objects.requireNonNull(Version.parse(dseRequirement.max()));
-          if (maxVersion.compareTo(dseVersion) <= 0) {
-            fireRequirementsNotMet(notifier, description, dseRequirement.min(), true, true);
-            return;
-          }
-        }
-      }
-    }
-    super.run(notifier);
-  }
+    BackendType backend =
+        CCM_BRIDGE.getDseVersion().isPresent() ? BackendType.DSE : BackendType.CASSANDRA;
+    Version version = CCM_BRIDGE.getDseVersion().orElseGet(CCM_BRIDGE::getCassandraVersion);
 
-  private void fireRequirementsNotMet(
-      RunNotifier notifier,
-      Description description,
-      String requirement,
-      boolean lessThan,
-      boolean dse) {
-    AssumptionViolatedException e =
-        new AssumptionViolatedException(
-            String.format(
-                "Test requires %s %s %s but %s is configured.  Description: %s",
-                lessThan ? "less than" : "at least",
-                dse ? "DSE" : "C*",
-                requirement,
-                dse ? CCM_BRIDGE.getDseVersion().orElse(null) : CCM_BRIDGE.getCassandraVersion(),
-                description));
-    notifier.fireTestAssumptionFailed(new Failure(description, e));
+    Collection<VersionRequirement> requirements =
+        VersionRequirement.fromAnnotations(getDescription());
+    if (VersionRequirement.meetsAny(requirements, backend, version)) {
+      super.run(notifier);
+    } else {
+      // requirements not met, throw reasoning assumption to skip test
+      AssumptionViolatedException e =
+          new AssumptionViolatedException(
+              VersionRequirement.buildReasonString(requirements, backend, version));
+      notifier.fireTestAssumptionFailed(new Failure(description, e));
+    }
   }
 }
diff --git a/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/ccm/BaseCcmRule.java b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/ccm/BaseCcmRule.java
index c902434..d4830dd 100644
--- a/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/ccm/BaseCcmRule.java
+++ b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/ccm/BaseCcmRule.java
@@ -18,9 +18,10 @@
 import com.datastax.oss.driver.api.core.DefaultProtocolVersion;
 import com.datastax.oss.driver.api.core.ProtocolVersion;
 import com.datastax.oss.driver.api.core.Version;
-import com.datastax.oss.driver.api.testinfra.CassandraRequirement;
 import com.datastax.oss.driver.api.testinfra.CassandraResourceRule;
-import com.datastax.oss.driver.api.testinfra.DseRequirement;
+import com.datastax.oss.driver.api.testinfra.requirement.BackendType;
+import com.datastax.oss.driver.api.testinfra.requirement.VersionRequirement;
+import java.util.Collection;
 import java.util.Optional;
 import org.junit.AssumptionViolatedException;
 import org.junit.runner.Description;
@@ -55,80 +56,26 @@
     ccmBridge.remove();
   }
 
-  private Statement buildErrorStatement(
-      Version requirement, String description, boolean lessThan, boolean dse) {
-    return new Statement() {
-
-      @Override
-      public void evaluate() {
-        throw new AssumptionViolatedException(
-            String.format(
-                "Test requires %s %s %s but %s is configured.  Description: %s",
-                lessThan ? "less than" : "at least",
-                dse ? "DSE" : "C*",
-                requirement,
-                dse ? ccmBridge.getDseVersion().orElse(null) : ccmBridge.getCassandraVersion(),
-                description));
-      }
-    };
-  }
-
   @Override
   public Statement apply(Statement base, Description description) {
-    // If test is annotated with CassandraRequirement or DseRequirement, ensure configured CCM
-    // cluster meets those requirements.
-    CassandraRequirement cassandraRequirement =
-        description.getAnnotation(CassandraRequirement.class);
+    BackendType backend =
+        ccmBridge.getDseVersion().isPresent() ? BackendType.DSE : BackendType.CASSANDRA;
+    Version version = ccmBridge.getDseVersion().orElseGet(ccmBridge::getCassandraVersion);
 
-    if (cassandraRequirement != null) {
-      // if the configured cassandra cassandraRequirement exceeds the one being used skip this test.
-      if (!cassandraRequirement.min().isEmpty()) {
-        Version minVersion = Version.parse(cassandraRequirement.min());
-        if (minVersion.compareTo(ccmBridge.getCassandraVersion()) > 0) {
-          return buildErrorStatement(minVersion, cassandraRequirement.description(), false, false);
+    Collection<VersionRequirement> requirements = VersionRequirement.fromAnnotations(description);
+
+    if (VersionRequirement.meetsAny(requirements, backend, version)) {
+      return super.apply(base, description);
+    } else {
+      // requirements not met, throw reasoning assumption to skip test
+      return new Statement() {
+        @Override
+        public void evaluate() {
+          throw new AssumptionViolatedException(
+              VersionRequirement.buildReasonString(requirements, backend, version));
         }
-      }
-
-      if (!cassandraRequirement.max().isEmpty()) {
-        // if the test version exceeds the maximum configured one, fail out.
-        Version maxVersion = Version.parse(cassandraRequirement.max());
-
-        if (maxVersion.compareTo(ccmBridge.getCassandraVersion()) <= 0) {
-          return buildErrorStatement(maxVersion, cassandraRequirement.description(), true, false);
-        }
-      }
+      };
     }
-
-    DseRequirement dseRequirement = description.getAnnotation(DseRequirement.class);
-    if (dseRequirement != null) {
-      Optional<Version> dseVersionOption = ccmBridge.getDseVersion();
-      if (!dseVersionOption.isPresent()) {
-        return new Statement() {
-
-          @Override
-          public void evaluate() {
-            throw new AssumptionViolatedException("Test Requires DSE but C* is configured.");
-          }
-        };
-      } else {
-        Version dseVersion = dseVersionOption.get();
-        if (!dseRequirement.min().isEmpty()) {
-          Version minVersion = Version.parse(dseRequirement.min());
-          if (minVersion.compareTo(dseVersion) > 0) {
-            return buildErrorStatement(minVersion, dseRequirement.description(), false, true);
-          }
-        }
-
-        if (!dseRequirement.max().isEmpty()) {
-          Version maxVersion = Version.parse(dseRequirement.max());
-
-          if (maxVersion.compareTo(dseVersion) <= 0) {
-            return buildErrorStatement(maxVersion, dseRequirement.description(), true, true);
-          }
-        }
-      }
-    }
-    return super.apply(base, description);
   }
 
   public Version getCassandraVersion() {
diff --git a/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendRequirement.java b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendRequirement.java
new file mode 100644
index 0000000..ec034db
--- /dev/null
+++ b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendRequirement.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright DataStax, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.datastax.oss.driver.api.testinfra.requirement;
+
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Annotation for a Class or Method that defines a database backend Version requirement. If the
+ * type/version in use does not meet the requirement, the test is skipped.
+ */
+@Repeatable(BackendRequirements.class)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface BackendRequirement {
+  BackendType type();
+
+  String minInclusive() default "";
+
+  String maxExclusive() default "";
+
+  String description() default "";
+}
diff --git a/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendRequirements.java b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendRequirements.java
new file mode 100644
index 0000000..09786c1
--- /dev/null
+++ b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendRequirements.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright DataStax, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.datastax.oss.driver.api.testinfra.requirement;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** Annotation to allow @BackendRequirement to be repeatable. */
+@Retention(RetentionPolicy.RUNTIME)
+public @interface BackendRequirements {
+  BackendRequirement[] value();
+}
diff --git a/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendType.java b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendType.java
new file mode 100644
index 0000000..eae7067
--- /dev/null
+++ b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/BackendType.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright DataStax, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.datastax.oss.driver.api.testinfra.requirement;
+
+public enum BackendType {
+  CASSANDRA("C*"),
+  DSE("Dse"),
+  ;
+
+  final String friendlyName;
+
+  BackendType(String friendlyName) {
+    this.friendlyName = friendlyName;
+  }
+
+  public String getFriendlyName() {
+    return friendlyName;
+  }
+}
diff --git a/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/VersionRequirement.java b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/VersionRequirement.java
new file mode 100644
index 0000000..28a72bc
--- /dev/null
+++ b/test-infra/src/main/java/com/datastax/oss/driver/api/testinfra/requirement/VersionRequirement.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright DataStax, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.datastax.oss.driver.api.testinfra.requirement;
+
+import com.datastax.oss.driver.api.core.Version;
+import com.datastax.oss.driver.api.testinfra.CassandraRequirement;
+import com.datastax.oss.driver.api.testinfra.DseRequirement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.junit.runner.Description;
+
+/**
+ * Used to unify the requirements specified by
+ * annotations @CassandraRequirement, @DseRequirment, @BackendRequirement
+ */
+public class VersionRequirement {
+  final BackendType backendType;
+  final Optional<Version> minInclusive;
+  final Optional<Version> maxExclusive;
+  final String description;
+
+  public VersionRequirement(
+      BackendType backendType, String minInclusive, String maxExclusive, String description) {
+    this.backendType = backendType;
+    this.minInclusive =
+        minInclusive.isEmpty() ? Optional.empty() : Optional.of(Version.parse(minInclusive));
+    this.maxExclusive =
+        maxExclusive.isEmpty() ? Optional.empty() : Optional.of(Version.parse(maxExclusive));
+    this.description = description;
+  }
+
+  public BackendType getBackendType() {
+    return backendType;
+  }
+
+  public Optional<Version> getMinInclusive() {
+    return minInclusive;
+  }
+
+  public Optional<Version> getMaxExclusive() {
+    return maxExclusive;
+  }
+
+  public String readableString() {
+    final String versionRange;
+    if (minInclusive.isPresent() && maxExclusive.isPresent()) {
+      versionRange =
+          String.format("%s or greater, but less than %s", minInclusive.get(), maxExclusive.get());
+    } else if (minInclusive.isPresent()) {
+      versionRange = String.format("%s or greater", minInclusive.get());
+    } else if (maxExclusive.isPresent()) {
+      versionRange = String.format("less than %s", maxExclusive.get());
+    } else {
+      versionRange = "any version";
+    }
+
+    if (!description.isEmpty()) {
+      return String.format("%s %s [%s]", backendType.getFriendlyName(), versionRange, description);
+    } else {
+      return String.format("%s %s", backendType.getFriendlyName(), versionRange);
+    }
+  }
+
+  public static VersionRequirement fromBackendRequirement(BackendRequirement requirement) {
+    return new VersionRequirement(
+        requirement.type(),
+        requirement.minInclusive(),
+        requirement.maxExclusive(),
+        requirement.description());
+  }
+
+  public static VersionRequirement fromCassandraRequirement(CassandraRequirement requirement) {
+    return new VersionRequirement(
+        BackendType.CASSANDRA, requirement.min(), requirement.max(), requirement.description());
+  }
+
+  public static VersionRequirement fromDseRequirement(DseRequirement requirement) {
+    return new VersionRequirement(
+        BackendType.DSE, requirement.min(), requirement.max(), requirement.description());
+  }
+
+  public static Collection<VersionRequirement> fromAnnotations(Description description) {
+    // collect all requirement annotation types
+    CassandraRequirement cassandraRequirement =
+        description.getAnnotation(CassandraRequirement.class);
+    DseRequirement dseRequirement = description.getAnnotation(DseRequirement.class);
+    BackendRequirements backendRequirements = description.getAnnotation(BackendRequirements.class);
+
+    // build list of required versions
+    Collection<VersionRequirement> requirements = new ArrayList<>();
+    if (cassandraRequirement != null) {
+      requirements.add(VersionRequirement.fromCassandraRequirement(cassandraRequirement));
+    }
+    if (dseRequirement != null) {
+      requirements.add(VersionRequirement.fromDseRequirement(dseRequirement));
+    }
+    if (backendRequirements != null) {
+      Arrays.stream(backendRequirements.value())
+          .forEach(r -> requirements.add(VersionRequirement.fromBackendRequirement(r)));
+    }
+    return requirements;
+  }
+
+  public static boolean meetsAny(
+      Collection<VersionRequirement> requirements,
+      BackendType configuredBackend,
+      Version configuredVersion) {
+    // special case: if there are no requirements then any backend/version is sufficient
+    if (requirements.isEmpty()) {
+      return true;
+    }
+
+    return requirements.stream()
+        .anyMatch(
+            requirement -> {
+              // requirement is different db type
+              if (requirement.getBackendType() != configuredBackend) {
+                return false;
+              }
+
+              // configured version is less than requirement min
+              if (requirement.getMinInclusive().isPresent()) {
+                if (requirement.getMinInclusive().get().compareTo(configuredVersion) > 0) {
+                  return false;
+                }
+              }
+
+              // configured version is greater than or equal to requirement max
+              if (requirement.getMaxExclusive().isPresent()) {
+                if (requirement.getMaxExclusive().get().compareTo(configuredVersion) <= 0) {
+                  return false;
+                }
+              }
+
+              // backend type and version range match
+              return true;
+            });
+  }
+
+  public static String buildReasonString(
+      Collection<VersionRequirement> requirements, BackendType backend, Version version) {
+    return String.format(
+        "Test requires one of:\n%s\nbut configuration is %s %s.",
+        requirements.stream()
+            .map(req -> String.format("  - %s", req.readableString()))
+            .collect(Collectors.joining("\n")),
+        backend.getFriendlyName(),
+        version);
+  }
+}
diff --git a/test-infra/src/test/java/com/datastax/oss/driver/api/testinfra/requirement/VersionRequirementTest.java b/test-infra/src/test/java/com/datastax/oss/driver/api/testinfra/requirement/VersionRequirementTest.java
new file mode 100644
index 0000000..51a362d
--- /dev/null
+++ b/test-infra/src/test/java/com/datastax/oss/driver/api/testinfra/requirement/VersionRequirementTest.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright DataStax, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.datastax.oss.driver.api.testinfra.requirement;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.datastax.oss.driver.api.core.Version;
+import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+
+public class VersionRequirementTest {
+  // backend aliases
+  private static BackendType CASSANDRA = BackendType.CASSANDRA;
+  private static BackendType DSE = BackendType.DSE;
+
+  // version numbers
+  private static Version V_0_0_0 = Version.parse("0.0.0");
+  private static Version V_0_1_0 = Version.parse("0.1.0");
+  private static Version V_1_0_0 = Version.parse("1.0.0");
+  private static Version V_1_0_1 = Version.parse("1.0.1");
+  private static Version V_1_1_0 = Version.parse("1.1.0");
+  private static Version V_2_0_0 = Version.parse("2.0.0");
+  private static Version V_2_0_1 = Version.parse("2.0.1");
+  private static Version V_3_0_0 = Version.parse("3.0.0");
+  private static Version V_3_1_0 = Version.parse("3.1.0");
+  private static Version V_4_0_0 = Version.parse("4.0.0");
+
+  // requirements
+  private static VersionRequirement CASSANDRA_ANY = new VersionRequirement(CASSANDRA, "", "", "");
+  private static VersionRequirement CASSANDRA_FROM_1_0_0 =
+      new VersionRequirement(CASSANDRA, "1.0.0", "", "");
+  private static VersionRequirement CASSANDRA_TO_1_0_0 =
+      new VersionRequirement(CASSANDRA, "", "1.0.0", "");
+  private static VersionRequirement CASSANDRA_FROM_1_0_0_TO_2_0_0 =
+      new VersionRequirement(CASSANDRA, "1.0.0", "2.0.0", "");
+  private static VersionRequirement CASSANDRA_FROM_1_1_0 =
+      new VersionRequirement(CASSANDRA, "1.1.0", "", "");
+  private static VersionRequirement CASSANDRA_FROM_3_0_0_TO_3_1_0 =
+      new VersionRequirement(CASSANDRA, "3.0.0", "3.1.0", "");
+  private static VersionRequirement DSE_ANY = new VersionRequirement(DSE, "", "", "");
+
+  @Test
+  public void empty_requirements() {
+    List<VersionRequirement> req = Collections.emptyList();
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_0_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_1_0_0)).isTrue();
+  }
+
+  @Test
+  public void single_requirement_any_version() {
+    List<VersionRequirement> anyCassandra = Collections.singletonList(CASSANDRA_ANY);
+    List<VersionRequirement> anyDse = Collections.singletonList(DSE_ANY);
+
+    assertThat(VersionRequirement.meetsAny(anyCassandra, CASSANDRA, V_0_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(anyCassandra, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(anyDse, DSE, V_0_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(anyDse, DSE, V_1_0_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(anyDse, CASSANDRA, V_0_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(anyDse, CASSANDRA, V_1_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(anyCassandra, DSE, V_0_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(anyCassandra, DSE, V_1_0_0)).isFalse();
+  }
+
+  @Test
+  public void single_requirement_min_only() {
+    List<VersionRequirement> req = Collections.singletonList(CASSANDRA_FROM_1_0_0);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_1)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_1_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_2_0_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_1_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_1_0)).isFalse();
+  }
+
+  @Test
+  public void single_requirement_max_only() {
+    List<VersionRequirement> req = Collections.singletonList(CASSANDRA_TO_1_0_0);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_1_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_0_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_1)).isFalse();
+  }
+
+  @Test
+  public void single_requirement_min_and_max() {
+    List<VersionRequirement> req = Collections.singletonList(CASSANDRA_FROM_1_0_0_TO_2_0_0);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_1)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_1_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_1_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_1_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_2_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_2_0_1)).isFalse();
+  }
+
+  @Test
+  public void multi_requirement_any_version() {
+    List<VersionRequirement> req = ImmutableList.of(CASSANDRA_ANY, DSE_ANY);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_1_0_0)).isTrue();
+  }
+
+  @Test
+  public void multi_db_requirement_min_one_any_other() {
+    List<VersionRequirement> req = ImmutableList.of(CASSANDRA_FROM_1_0_0, DSE_ANY);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_2_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_0_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_1_0_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isFalse();
+  }
+
+  @Test
+  public void multi_requirement_two_ranges() {
+    List<VersionRequirement> req =
+        ImmutableList.of(CASSANDRA_FROM_1_0_0_TO_2_0_0, CASSANDRA_FROM_3_0_0_TO_3_1_0);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_1_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_3_0_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_1_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_2_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_3_1_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_4_0_0)).isFalse();
+  }
+
+  @Test
+  public void multi_requirement_overlapping() {
+    List<VersionRequirement> req =
+        ImmutableList.of(CASSANDRA_FROM_1_0_0_TO_2_0_0, CASSANDRA_FROM_1_1_0);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_1_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_2_0_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(req, DSE, V_1_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isFalse();
+  }
+
+  @Test
+  public void multi_requirement_not_range() {
+    List<VersionRequirement> req = ImmutableList.of(CASSANDRA_TO_1_0_0, CASSANDRA_FROM_1_1_0);
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_0_0_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_1_0)).isTrue();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_2_0_0)).isTrue();
+
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_0)).isFalse();
+    assertThat(VersionRequirement.meetsAny(req, CASSANDRA, V_1_0_1)).isFalse();
+  }
+}