diff --git a/hoptimator-api/build.gradle b/hoptimator-api/build.gradle
index 571a1964..d73eca13 100644
--- a/hoptimator-api/build.gradle
+++ b/hoptimator-api/build.gradle
@@ -4,7 +4,8 @@ plugins {
}
dependencies {
- // plz keep it this way
+ // This package should have minimal dependencies
+ compileOnly 'com.google.code.findbugs:jsr305:3.0.2'
}
publishing {
diff --git a/hoptimator-api/src/main/java/com/linkedin/hoptimator/PendingDelete.java b/hoptimator-api/src/main/java/com/linkedin/hoptimator/PendingDelete.java
new file mode 100644
index 00000000..5c330aa8
--- /dev/null
+++ b/hoptimator-api/src/main/java/com/linkedin/hoptimator/PendingDelete.java
@@ -0,0 +1,55 @@
+package com.linkedin.hoptimator;
+
+import java.util.Objects;
+import javax.annotation.Nullable;
+
+
+/**
+ * A type-tagged wrapper signaling that {@code target} is about to be deleted. Pre-delete
+ * validators (e.g. dependency guards that block DROP TABLE when a pipeline still references
+ * the resource) key off this wrapper rather than the raw target type — so an unrelated future
+ * caller of {@code ValidationService.validateOrThrow(source, connection)} doesn't accidentally
+ * trigger delete-intent checks.
+ *
+ *
An optional {@code (selfOwnerKind, selfOwnerName)} lets the caller declare an "umbrella"
+ * K8s resource whose owned objects should be excluded from the dependent set — e.g. a
+ * LogicalTable CRD, so its child Pipeline CRDs (which reference tier sources by SQL) don't
+ * self-block the drop.
+ */
+public final class PendingDelete {
+
+ private final T target;
+ private final String selfOwnerKind;
+ private final String selfOwnerName;
+
+ public PendingDelete(T target) {
+ this(target, null, null);
+ }
+
+ public PendingDelete(T target, @Nullable String selfOwnerKind, @Nullable String selfOwnerName) {
+ this.target = Objects.requireNonNull(target, "target");
+ this.selfOwnerKind = selfOwnerKind;
+ this.selfOwnerName = selfOwnerName;
+ }
+
+ public T target() {
+ return target;
+ }
+
+ /** Kind of the K8s resource whose owned objects should be excluded from the dependent set. */
+ public @Nullable String selfOwnerKind() {
+ return selfOwnerKind;
+ }
+
+ /** Name of the K8s resource whose owned objects should be excluded from the dependent set. */
+ public @Nullable String selfOwnerName() {
+ return selfOwnerName;
+ }
+
+ @Override
+ public String toString() {
+ String self = (selfOwnerKind != null && selfOwnerName != null)
+ ? ", self=" + selfOwnerKind + "/" + selfOwnerName : "";
+ return "PendingDelete[" + target + self + "]";
+ }
+}
diff --git a/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validated.java b/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validated.java
index fae19bcf..7c32c148 100644
--- a/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validated.java
+++ b/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validated.java
@@ -1,6 +1,14 @@
package com.linkedin.hoptimator;
+import java.sql.Connection;
+
+
public interface Validated {
- void validate(Validator.Issues issues);
+ /**
+ * Validates {@code this}, recording any problems in {@code issues}. The connection is always
+ * supplied so validators can run lookups against external systems (e.g. pre-delete dependency
+ * checks).
+ */
+ void validate(Validator.Issues issues, Connection connection);
}
diff --git a/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validator.java b/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validator.java
index 82e72831..9db6a279 100644
--- a/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validator.java
+++ b/hoptimator-api/src/main/java/com/linkedin/hoptimator/Validator.java
@@ -1,5 +1,6 @@
package com.linkedin.hoptimator;
+import java.sql.Connection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -52,8 +53,8 @@ public DefaultValidator(T t) {
}
@Override
- public void validate(Issues issues) {
- t.validate(issues.child(t.getClass().getSimpleName()));
+ public void validate(Issues issues, Connection connection) {
+ t.validate(issues.child(t.getClass().getSimpleName()), connection);
}
}
diff --git a/hoptimator-api/src/main/java/com/linkedin/hoptimator/ValidatorProvider.java b/hoptimator-api/src/main/java/com/linkedin/hoptimator/ValidatorProvider.java
index 55b60a44..d4729e4a 100644
--- a/hoptimator-api/src/main/java/com/linkedin/hoptimator/ValidatorProvider.java
+++ b/hoptimator-api/src/main/java/com/linkedin/hoptimator/ValidatorProvider.java
@@ -1,9 +1,13 @@
package com.linkedin.hoptimator;
+import java.sql.Connection;
import java.util.Collection;
public interface ValidatorProvider {
- Collection validators(T obj);
+ /**
+ * Returns validators that should be applied to {@code obj}.
+ */
+ Collection validators(T obj, Connection connection);
}
diff --git a/hoptimator-api/src/test/java/com/linkedin/hoptimator/PendingDeleteTest.java b/hoptimator-api/src/test/java/com/linkedin/hoptimator/PendingDeleteTest.java
new file mode 100644
index 00000000..4b5b9144
--- /dev/null
+++ b/hoptimator-api/src/test/java/com/linkedin/hoptimator/PendingDeleteTest.java
@@ -0,0 +1,85 @@
+package com.linkedin.hoptimator;
+
+import org.junit.jupiter.api.Test;
+
+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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+class PendingDeleteTest {
+
+ @Test
+ void singleArgConstructorLeavesSelfOwnerNull() {
+ Object target = new Object();
+ PendingDelete pd = new PendingDelete<>(target);
+
+ assertSame(target, pd.target());
+ assertNull(pd.selfOwnerKind());
+ assertNull(pd.selfOwnerName());
+ }
+
+ @Test
+ void threeArgConstructorStoresSelfOwner() {
+ Object target = new Object();
+ PendingDelete pd = new PendingDelete<>(target, "LogicalTable", "my-table");
+
+ assertSame(target, pd.target());
+ assertEquals("LogicalTable", pd.selfOwnerKind());
+ assertEquals("my-table", pd.selfOwnerName());
+ }
+
+ @Test
+ void threeArgConstructorAcceptsNullSelfOwner() {
+ Object target = new Object();
+ PendingDelete pd = new PendingDelete<>(target, null, null);
+
+ assertNull(pd.selfOwnerKind());
+ assertNull(pd.selfOwnerName());
+ }
+
+ @Test
+ void nullTargetThrows() {
+ assertThrows(NullPointerException.class, () -> new PendingDelete<>(null));
+ assertThrows(NullPointerException.class,
+ () -> new PendingDelete<>(null, "LogicalTable", "my-table"));
+ }
+
+ @Test
+ void toStringIncludesTargetAndSelfOwnerWhenPresent() {
+ PendingDelete pd = new PendingDelete<>("the-target", "LogicalTable", "my-table");
+ String s = pd.toString();
+ assertTrue(s.contains("the-target"), "toString should include target: " + s);
+ assertTrue(s.contains("LogicalTable/my-table"), "toString should include kind/name: " + s);
+ }
+
+ @Test
+ void toStringOmitsSelfOwnerWhenNull() {
+ PendingDelete pd = new PendingDelete<>("the-target");
+ String s = pd.toString();
+ assertTrue(s.contains("the-target"));
+ assertFalse(s.contains("self="), "toString should not include self= when not set: " + s);
+ }
+
+ @Test
+ void toStringOmitsSelfOwnerWhenOnlyOneFieldSet() {
+ // The toString contract says self= appears only when both kind and name are non-null.
+ // (The K8sPipelineDependencyChecker.isSelfOwned guard also requires both.)
+ PendingDelete kindOnly = new PendingDelete<>("t", "LogicalTable", null);
+ assertFalse(kindOnly.toString().contains("self="));
+
+ PendingDelete nameOnly = new PendingDelete<>("t", null, "my-table");
+ assertFalse(nameOnly.toString().contains("self="));
+ }
+
+ @Test
+ void targetGenericTypeIsPreserved() {
+ Source source = new Source("db", java.util.List.of("schema", "tbl"), java.util.Map.of());
+ PendingDelete pd = new PendingDelete<>(source);
+ Source unwrapped = pd.target();
+ assertEquals("tbl", unwrapped.table());
+ }
+}
diff --git a/hoptimator-api/src/test/java/com/linkedin/hoptimator/ValidatorTest.java b/hoptimator-api/src/test/java/com/linkedin/hoptimator/ValidatorTest.java
index ddd98438..ab1f4719 100644
--- a/hoptimator-api/src/test/java/com/linkedin/hoptimator/ValidatorTest.java
+++ b/hoptimator-api/src/test/java/com/linkedin/hoptimator/ValidatorTest.java
@@ -184,10 +184,10 @@ void testCheckClosedMessageContainsPath() {
@Test
void testDefaultValidatorDelegatesToTarget() {
- Validated target = issues -> issues.error("target error");
+ Validated target = (issues, conn) -> issues.error("target error");
Validator.DefaultValidator validator = new Validator.DefaultValidator<>(target);
Validator.Issues issues = new Validator.Issues("root");
- validator.validate(issues);
+ validator.validate(issues, null);
assertFalse(issues.valid());
}
diff --git a/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroTableValidator.java b/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroTableValidator.java
index 8d9f45eb..57d35fed 100644
--- a/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroTableValidator.java
+++ b/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroTableValidator.java
@@ -17,6 +17,7 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
+import java.sql.Connection;
/** Validates that tables follow Avro schema evolution rules. */
@@ -29,7 +30,7 @@ class AvroTableValidator implements Validator {
}
@Override
- public void validate(Issues issues) {
+ public void validate(Issues issues, Connection connection) {
try {
CalciteSchema originalSchema = schema.unwrap(CalciteSchema.class);
if (originalSchema == null || originalSchema.schema == null) {
diff --git a/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroValidatorProvider.java b/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroValidatorProvider.java
index 0f4c3ef7..de5a986b 100644
--- a/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroValidatorProvider.java
+++ b/hoptimator-avro/src/main/java/com/linkedin/hoptimator/avro/AvroValidatorProvider.java
@@ -4,6 +4,7 @@
import com.linkedin.hoptimator.ValidatorProvider;
import org.apache.calcite.schema.SchemaPlus;
+import java.sql.Connection;
import java.util.Collection;
import java.util.Collections;
@@ -12,7 +13,7 @@
public class AvroValidatorProvider implements ValidatorProvider {
@Override
- public Collection validators(T obj) {
+ public Collection validators(T obj, Connection connection) {
if (obj instanceof SchemaPlus) {
return Collections.singletonList(new AvroTableValidator((SchemaPlus) obj));
} else {
diff --git a/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroTableValidatorTest.java b/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroTableValidatorTest.java
index 0d10c71e..b843bf96 100644
--- a/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroTableValidatorTest.java
+++ b/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroTableValidatorTest.java
@@ -41,7 +41,7 @@ void testValidateCatchesClassCastExceptionSilently() {
AvroTableValidator validator = new AvroTableValidator(schema);
Validator.Issues issues = new Validator.Issues("test");
- validator.validate(issues);
+ validator.validate(issues, null);
assertTrue(issues.valid(), "ClassCastException should be silently caught");
}
@@ -53,7 +53,7 @@ void testValidateThrowsForNullOriginalSchema() {
AvroTableValidator validator = new AvroTableValidator(schema);
Validator.Issues issues = new Validator.Issues("test");
- assertThrows(IllegalArgumentException.class, () -> validator.validate(issues));
+ assertThrows(IllegalArgumentException.class, () -> validator.validate(issues, null));
}
@Test
@@ -99,7 +99,7 @@ protected Map getTableMap() {
AvroTableValidator validator = new AvroTableValidator(schema);
Validator.Issues issues = new Validator.Issues("root");
- validator.validate(issues);
+ validator.validate(issues, null);
assertFalse(issues.valid(),
"Incompatible schema (INT→VARCHAR) should produce validation errors");
@@ -137,7 +137,7 @@ protected Map getTableMap() {
AvroTableValidator validator = new AvroTableValidator(schema);
Validator.Issues issues = new Validator.Issues("root");
- validator.validate(issues);
+ validator.validate(issues, null);
assertTrue(issues.valid(), "Compatible schemas should pass validation without errors");
}
diff --git a/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroValidatorProviderTest.java b/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroValidatorProviderTest.java
index 6ce8ba25..0bb41746 100644
--- a/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroValidatorProviderTest.java
+++ b/hoptimator-avro/src/test/java/com/linkedin/hoptimator/avro/AvroValidatorProviderTest.java
@@ -24,7 +24,7 @@ class AvroValidatorProviderTest {
void testValidatorsReturnsAvroTableValidatorForSchemaPlus() {
AvroValidatorProvider provider = new AvroValidatorProvider();
- Collection validators = provider.validators(schemaPlus);
+ Collection validators = provider.validators(schemaPlus, null);
assertEquals(1, validators.size());
assertInstanceOf(AvroTableValidator.class, validators.iterator().next());
@@ -34,7 +34,7 @@ void testValidatorsReturnsAvroTableValidatorForSchemaPlus() {
void testValidatorsReturnsEmptyForNonSchemaPlus() {
AvroValidatorProvider provider = new AvroValidatorProvider();
- Collection validators = provider.validators("not-a-schema");
+ Collection validators = provider.validators("not-a-schema", null);
assertTrue(validators.isEmpty());
}
@@ -43,7 +43,7 @@ void testValidatorsReturnsEmptyForNonSchemaPlus() {
void testValidatorsReturnsEmptyForNull() {
AvroValidatorProvider provider = new AvroValidatorProvider();
- Collection validators = provider.validators(null);
+ Collection validators = provider.validators(null, null);
assertTrue(validators.isEmpty());
}
diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBase.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBase.java
index 44a1de00..c6a8b12f 100644
--- a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBase.java
+++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBase.java
@@ -6,6 +6,8 @@
import org.apache.calcite.schema.Table;
import org.apache.calcite.schema.lookup.LikePattern;
+import java.sql.Connection;
+
/** Base class for shared schema evolution validators. */
abstract class CompatibilityValidatorBase implements Validator {
@@ -17,7 +19,7 @@ abstract class CompatibilityValidatorBase implements Validator {
}
@Override
- public void validate(Issues issues) {
+ public void validate(Issues issues, Connection connection) {
try {
CalciteSchema originalSchema = schema.unwrap(CalciteSchema.class);
if (originalSchema == null || originalSchema.schema == null) {
diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProvider.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProvider.java
index 46579b8b..32588782 100644
--- a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProvider.java
+++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProvider.java
@@ -4,6 +4,7 @@
import com.linkedin.hoptimator.ValidatorProvider;
import org.apache.calcite.schema.SchemaPlus;
+import java.sql.Connection;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@@ -13,7 +14,7 @@
public class CompatibilityValidatorProvider implements ValidatorProvider {
@Override
- public Collection validators(T obj) {
+ public Collection validators(T obj, Connection connection) {
if (obj instanceof SchemaPlus) {
return Arrays.asList(new Validator[]{new BackwardCompatibilityValidator((SchemaPlus) obj),
new ForwardCompatibilityValidator((SchemaPlus) obj)});
diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProvider.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProvider.java
index 8461b26e..fb21346e 100644
--- a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProvider.java
+++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProvider.java
@@ -4,6 +4,7 @@
import com.linkedin.hoptimator.Validator;
import com.linkedin.hoptimator.ValidatorProvider;
+import java.sql.Connection;
import java.util.Collection;
import java.util.Collections;
@@ -12,7 +13,7 @@
public class DefaultValidatorProvider implements ValidatorProvider {
@Override
- public Collection validators(T obj) {
+ public Collection validators(T obj, Connection connection) {
if (obj instanceof Validated) {
return Collections.singletonList(new Validator.DefaultValidator<>((Validated) obj));
} else {
diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutor.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutor.java
index 678de93b..18e229e7 100644
--- a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutor.java
+++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutor.java
@@ -20,6 +20,7 @@
package com.linkedin.hoptimator.jdbc;
import com.linkedin.hoptimator.Deployer;
+import com.linkedin.hoptimator.PendingDelete;
import com.linkedin.hoptimator.Source;
import com.linkedin.hoptimator.Trigger;
import com.linkedin.hoptimator.UserJob;
@@ -107,7 +108,7 @@ public DdlExecutor getDdlExecutor() {
public void execute(SqlCreateView create, CalcitePrepare.Context context) {
logger.info("Validating statement: {}", create);
try {
- ValidationService.validateOrThrow(create);
+ ValidationService.validateOrThrow(create, connection);
} catch (SQLException e) {
throw new DdlException(create, e.getMessage(), e);
}
@@ -145,9 +146,9 @@ public void execute(SqlCreateView create, CalcitePrepare.Context context) {
Collection deployers = null;
try {
logger.info("Validating deployable resources for view {}", viewName);
- ValidationService.validateOrThrow(viewTable);
+ ValidationService.validateOrThrow(viewTable, connection);
deployers = DeploymentService.deployers(view, connection);
- ValidationService.validateOrThrow(deployers);
+ ValidationService.validateOrThrow(deployers, connection);
logger.info("Validated view {}", viewName);
if (create.getReplace()) {
logger.info("Deploying update view {}", viewName);
@@ -195,7 +196,7 @@ public void execute(SqlCreateMaterializedView create, CalcitePrepare.Context con
public void execute(SqlCreateTrigger create, CalcitePrepare.Context context) {
logger.info("Validating statement: {}", create);
try {
- ValidationService.validateOrThrow(create);
+ ValidationService.validateOrThrow(create, connection);
} catch (SQLException e) {
throw new DdlException(create, e.getMessage(), e);
}
@@ -233,9 +234,9 @@ public void execute(SqlCreateTrigger create, CalcitePrepare.Context context) {
Collection deployers = null;
try {
logger.info("Validating trigger {} with deployers", name);
- ValidationService.validateOrThrow(trigger);
+ ValidationService.validateOrThrow(trigger, connection);
deployers = DeploymentService.deployers(trigger, connection);
- ValidationService.validateOrThrow(deployers);
+ ValidationService.validateOrThrow(deployers, connection);
logger.info("Validated trigger {}", name);
if (create.getReplace()) {
logger.info("Updating trigger {}", name);
@@ -296,7 +297,7 @@ public void execute(SqlResumeTrigger resume, CalcitePrepare.Context context) {
public void execute(SqlDropTrigger drop, CalcitePrepare.Context context) {
logger.info("Validating statement: {}", drop);
try {
- ValidationService.validateOrThrow(drop);
+ ValidationService.validateOrThrow(drop, connection);
} catch (SQLException e) {
throw new DdlException(drop, e.getMessage(), e);
}
@@ -331,7 +332,7 @@ public void execute(SqlDropTrigger drop, CalcitePrepare.Context context) {
private void updateTriggerPausedState(SqlNode sqlNode, SqlIdentifier triggerName, boolean paused) {
logger.info("Validating statement: {}", sqlNode);
try {
- ValidationService.validateOrThrow(sqlNode);
+ ValidationService.validateOrThrow(sqlNode, connection);
} catch (SQLException e) {
throw new DdlException(sqlNode, e.getMessage(), e);
}
@@ -366,7 +367,7 @@ private void updateTriggerPausedState(SqlNode sqlNode, SqlIdentifier triggerName
public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
logger.info("Validating statement: {}", drop);
try {
- ValidationService.validateOrThrow(drop);
+ ValidationService.validateOrThrow(drop, connection);
} catch (SQLException e) {
throw new DdlException(drop, e.getMessage(), e);
}
@@ -435,6 +436,10 @@ public void execute(SqlDropObject drop, CalcitePrepare.Context context) {
TemporaryTable temporaryTable = (TemporaryTable) table;
source = new Source(temporaryTable.databaseName(), tablePath, Collections.emptyMap());
}
+ // Pre-delete dependency guard. PendingDelete is the explicit "delete intent" signal
+ // — only validators that key off it (the K8s dep checker) fire here. The check throws
+ // before any deployer-level state change.
+ ValidationService.validateOrThrow(new PendingDelete<>(source), connection);
deployers = DeploymentService.deployers(source, connection);
logger.info("Deleting table {}", tableName);
DeploymentService.delete(deployers);
diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtils.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtils.java
index 6ff1e551..5e29f047 100644
--- a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtils.java
+++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtils.java
@@ -296,7 +296,7 @@ static SpecifyResult processCreateMaterializedView(CalcitePrepare.Context ctx,
HoptimatorConnection.HoptimatorConnectionDualLogger logger = conn.getLogger(HoptimatorDdlUtils.class);
// Validate the DDL statement.
logger.info("Validating statement: {}", create);
- ValidationService.validateOrThrow(create);
+ ValidationService.validateOrThrow(create, conn);
// Extract query SQL (rename columns if a column list was provided) and plan the query.
// This is done first — before schema/conflict checks — so that:
@@ -374,10 +374,10 @@ static SpecifyResult processCreateMaterializedView(CalcitePrepare.Context ctx,
// Validate the hook and its deployers.
logger.info("Validating materialized view {}", viewName);
- ValidationService.validateOrThrow(hook);
+ ValidationService.validateOrThrow(hook, conn);
deployers = DeploymentService.deployers(hook, conn);
logger.info("Validating deployable resources for materialized view {}", viewName);
- ValidationService.validateOrThrow(deployers);
+ ValidationService.validateOrThrow(deployers, conn);
logger.info("Validated materialized view {}", viewName);
// Execute (create/update) or collect specs (specify).
@@ -442,7 +442,7 @@ static SpecifyResult processCreateTable(CalcitePrepare.Context ctx, HoptimatorCo
HoptimatorConnection.HoptimatorConnectionDualLogger logger = conn.getLogger(HoptimatorDdlUtils.class);
logger.info("Validating statement: {}", create);
- ValidationService.validateOrThrow(create);
+ ValidationService.validateOrThrow(create, conn);
// TODO: Add support for populating new tables from a query as a one-time operation.
if (create.query != null) {
@@ -573,10 +573,10 @@ public RexNode newColumnDefaultValue(RelOptTable table, int iColumn,
boolean success = false;
try {
logger.info("Validating new table {}", source);
- ValidationService.validateOrThrow(source);
+ ValidationService.validateOrThrow(source, conn);
deployers = DeploymentService.deployers(source, conn);
logger.info("Validating deployable resources for table {}", tableName);
- ValidationService.validateOrThrow(deployers);
+ ValidationService.validateOrThrow(deployers, conn);
if (mode == DdlMode.UPDATE) {
logger.info("Deploying update table {}", source);
@@ -638,7 +638,7 @@ static SpecifyResult processCreateDatabase(HoptimatorConnection conn,
HoptimatorConnection.HoptimatorConnectionDualLogger logger = conn.getLogger(HoptimatorDdlUtils.class);
logger.info("Validating statement: {}", create);
- ValidationService.validateOrThrow(create);
+ ValidationService.validateOrThrow(create, conn);
if (create.name.names.size() > 1) {
throw new SQLException("Database names cannot be compound identifiers.");
@@ -651,9 +651,9 @@ static SpecifyResult processCreateDatabase(HoptimatorConnection conn,
Collection deployers = null;
try {
logger.info("Validating database {}", name);
- ValidationService.validateOrThrow(database);
+ ValidationService.validateOrThrow(database, conn);
deployers = DeploymentService.deployers(database, conn);
- ValidationService.validateOrThrow(deployers);
+ ValidationService.validateOrThrow(deployers, conn);
List specs = mode.executeDeployers(deployers, conn);
if (mode.mutable()) {
@@ -892,4 +892,37 @@ public static Runnable registerTemporaryTableInSchema(HoptimatorConnection conn,
return registerTemporaryTable(rootSchema, tableName, rowType, databaseName);
}
}
+
+ /**
+ * Removes {@code tableName} from the tier schema identified by {@code (catalog, schema)}, the
+ * inverse of {@link #registerTemporaryTableInSchema}. Silent no-op if the catalog, schema or
+ * table entry does not exist (e.g. the connection was opened after the table was already gone)
+ * — remove must never fail a user-visible DROP that has already succeeded at the backend.
+ */
+ public static void removeTableFromSchema(HoptimatorConnection conn,
+ @Nullable String catalog, @Nullable String schema, String tableName) {
+ if (conn == null) {
+ return;
+ }
+ SchemaPlus rootSchema = conn.calciteConnection().getRootSchema();
+ SchemaPlus target;
+ if (catalog != null) {
+ SchemaPlus catalogSchemaPlus = rootSchema.subSchemas().get(catalog);
+ if (catalogSchemaPlus == null) {
+ return;
+ }
+ if (schema == null) {
+ target = catalogSchemaPlus;
+ } else {
+ target = catalogSchemaPlus.subSchemas().get(schema);
+ }
+ } else if (schema != null) {
+ target = rootSchema.subSchemas().get(schema);
+ } else {
+ target = rootSchema;
+ }
+ if (target != null && target.tables().get(tableName) != null) {
+ target.removeTable(tableName);
+ }
+ }
}
diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ValidationService.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ValidationService.java
index 72fa2e0b..ccd6ceb5 100644
--- a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ValidationService.java
+++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ValidationService.java
@@ -28,40 +28,40 @@ public static Validator.Issues validate(Connection connection) {
}
CalciteConnection conn = (CalciteConnection) connection;
Validator.Issues issues = new Validator.Issues("");
- walk(conn.getRootSchema(), issues);
+ walk(conn.getRootSchema(), issues, connection);
return issues;
}
- private static void walk(SchemaPlus schema, Validator.Issues issues) {
- validate(schema, issues);
+ private static void walk(SchemaPlus schema, Validator.Issues issues, Connection connection) {
+ validate(schema, issues, connection);
for (String x : schema.subSchemas().getNames(LikePattern.any())) {
- walk(schema.subSchemas().get(x), issues.child(x));
+ walk(schema.subSchemas().get(x), issues.child(x), connection);
}
for (String x : schema.tables().getNames(LikePattern.any())) {
- walk(schema.tables().get(x), issues.child(x));
+ walk(schema.tables().get(x), issues.child(x), connection);
}
}
- private static void walk(Table table, Validator.Issues issues) {
- validate(table, issues);
+ private static void walk(Table table, Validator.Issues issues, Connection connection) {
+ validate(table, issues, connection);
}
- public static void validate(T obj, Validator.Issues issues) {
- validators(obj).forEach(x -> x.validate(issues));
+ public static void validate(T obj, Validator.Issues issues, Connection connection) {
+ validators(obj, connection).forEach(x -> x.validate(issues, connection));
}
- public static void validateOrThrow(T obj) throws SQLException {
+ public static void validateOrThrow(T obj, Connection connection) throws SQLException {
Validator.Issues issues = new Validator.Issues("");
- validate(obj, issues);
+ validate(obj, issues, connection);
if (!issues.valid()) {
throw new SQLDataException("Failed validation:\n" + issues);
}
}
- public static void validateOrThrow(Collection objs) throws SQLException {
+ public static void validateOrThrow(Collection objs, Connection connection) throws SQLException {
Validator.Issues issues = new Validator.Issues("");
for (T obj : objs) {
- validate(obj, issues);
+ validate(obj, issues, connection);
if (!issues.valid()) {
throw new SQLDataException("Failed validation:\n" + issues);
}
@@ -75,7 +75,7 @@ public static Collection providers() {
return providers;
}
- public static Collection validators(T obj) {
- return providers().stream().flatMap(x -> x.validators(obj).stream()).collect(Collectors.toList());
+ public static Collection validators(T obj, Connection connection) {
+ return providers().stream().flatMap(x -> x.validators(obj, connection).stream()).collect(Collectors.toList());
}
}
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CalciteDriverTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CalciteDriverTest.java
index 112b726c..b7bb841c 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CalciteDriverTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CalciteDriverTest.java
@@ -1,6 +1,5 @@
package com.linkedin.hoptimator.jdbc;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.jdbc.CalcitePrepare;
import org.junit.jupiter.api.Test;
@@ -15,8 +14,6 @@
import static org.junit.jupiter.api.Assertions.assertSame;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class CalciteDriverTest {
@Test
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBaseTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBaseTest.java
index 07dd51b4..b8ec8c23 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBaseTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorBaseTest.java
@@ -38,7 +38,7 @@ protected void validate(Table table, Table originalTable, Validator.Issues issue
};
Validator.Issues issues = new Validator.Issues("test");
- validator.validate(issues);
+ validator.validate(issues, null);
assertTrue(issues.valid());
}
@@ -57,7 +57,7 @@ protected void validate(Table table, Table originalTable, Validator.Issues issue
Validator.Issues issues = new Validator.Issues("test");
try {
- validator.validate(issues);
+ validator.validate(issues, null);
} catch (IllegalArgumentException e) {
assertTrue(e.getMessage().contains("Null original schema"));
}
@@ -99,7 +99,7 @@ protected void validate(Table table, Table originalTable, Validator.Issues issue
};
Validator.Issues issues = new Validator.Issues("test");
- validator.validate(issues);
+ validator.validate(issues, null);
assertTrue(issues.valid());
}
@@ -125,7 +125,7 @@ protected void validate(Table table, Table originalTable, Validator.Issues issue
};
Validator.Issues issues = new Validator.Issues("test");
- validator.validate(issues);
+ validator.validate(issues, null);
// brandNewTable won't have an original, so validate should be skipped
assertTrue(issues.valid());
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProviderTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProviderTest.java
index db2f3627..2fc0b8a4 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProviderTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/CompatibilityValidatorProviderTest.java
@@ -23,7 +23,7 @@ class CompatibilityValidatorProviderTest {
void testValidatorsReturnsTwoValidatorsForSchemaPlus() {
CompatibilityValidatorProvider provider = new CompatibilityValidatorProvider();
- Collection validators = provider.validators(mockSchema);
+ Collection validators = provider.validators(mockSchema, null);
assertEquals(2, validators.size());
}
@@ -32,7 +32,7 @@ void testValidatorsReturnsTwoValidatorsForSchemaPlus() {
void testValidatorsReturnsEmptyForNonSchemaPlus() {
CompatibilityValidatorProvider provider = new CompatibilityValidatorProvider();
- Collection validators = provider.validators("not-a-schema");
+ Collection validators = provider.validators("not-a-schema", null);
assertTrue(validators.isEmpty());
}
@@ -41,7 +41,7 @@ void testValidatorsReturnsEmptyForNonSchemaPlus() {
void testValidatorsReturnsEmptyForNull() {
CompatibilityValidatorProvider provider = new CompatibilityValidatorProvider();
- Collection validators = provider.validators(null);
+ Collection validators = provider.validators(null, null);
assertTrue(validators.isEmpty());
}
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProviderTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProviderTest.java
index ece661c8..f838f51f 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProviderTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/DefaultValidatorProviderTest.java
@@ -15,9 +15,9 @@ class DefaultValidatorProviderTest {
@Test
void testValidatorsReturnsValidatorForValidatedObject() {
DefaultValidatorProvider provider = new DefaultValidatorProvider();
- Validated validated = issues -> issues.error("test error");
+ Validated validated = (issues, conn) -> issues.error("test error");
- Collection validators = provider.validators(validated);
+ Collection validators = provider.validators(validated, null);
assertEquals(1, validators.size());
}
@@ -26,7 +26,7 @@ void testValidatorsReturnsValidatorForValidatedObject() {
void testValidatorsReturnsEmptyForNonValidatedObject() {
DefaultValidatorProvider provider = new DefaultValidatorProvider();
- Collection validators = provider.validators("not-validated");
+ Collection validators = provider.validators("not-validated", null);
assertTrue(validators.isEmpty());
}
@@ -35,7 +35,7 @@ void testValidatorsReturnsEmptyForNonValidatedObject() {
void testValidatorsReturnsEmptyForNull() {
DefaultValidatorProvider provider = new DefaultValidatorProvider();
- Collection validators = provider.validators(null);
+ Collection validators = provider.validators(null, null);
assertTrue(validators.isEmpty());
}
@@ -43,11 +43,11 @@ void testValidatorsReturnsEmptyForNull() {
@Test
void testReturnedValidatorDelegates() {
DefaultValidatorProvider provider = new DefaultValidatorProvider();
- Validated validated = issues -> issues.error("validation failed");
+ Validated validated = (issues, conn) -> issues.error("validation failed");
- Collection validators = provider.validators(validated);
+ Collection validators = provider.validators(validated, null);
Validator.Issues issues = new Validator.Issues("test");
- validators.iterator().next().validate(issues);
+ validators.iterator().next().validate(issues, null);
assertTrue(issues.toString().contains("validation failed"));
}
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorConnectionTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorConnectionTest.java
index 961b4f73..8fe4b57f 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorConnectionTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorConnectionTest.java
@@ -42,9 +42,13 @@
import static org.mockito.Mockito.when;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
@ExtendWith(MockitoExtension.class)
+@SuppressFBWarnings(
+ value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
+ justification = "Tests use when(mock.createStatement()/prepareStatement()).thenReturn(...) "
+ + "to stub and verify(mock).prepareStatement(...) to verify. Both are Mockito DSL "
+ + "invocations on methods whose AutoCloseable return types SpotBugs flags. There is no "
+ + "way to express these in the Mockito API without the method invocation on the mock.")
class HoptimatorConnectionTest {
@Mock
@@ -170,8 +174,6 @@ void testGetLoggerReturnsNonNull() {
}
@Test
- @SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Connection closed in try-with-resources")
void testRegisterMaterializationAddsMaterialization() throws SQLException {
HoptimatorDriver driver = new HoptimatorDriver();
try (HoptimatorConnection conn =
@@ -186,8 +188,6 @@ void testRegisterMaterializationAddsMaterialization() throws SQLException {
}
@Test
- @SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Connection closed in try-with-resources")
void testResolveThrowsForNonDatabaseSchema() throws SQLException {
HoptimatorDriver driver = new HoptimatorDriver();
try (HoptimatorConnection conn =
@@ -198,8 +198,6 @@ void testResolveThrowsForNonDatabaseSchema() throws SQLException {
}
@Test
- @SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Connection closed in try-with-resources")
void resolveReturnsNonNullTypeForExistingTwoPartPath() throws SQLException {
HoptimatorDriver driver = new HoptimatorDriver();
try (HoptimatorConnection conn =
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDatabaseMetaDataTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDatabaseMetaDataTest.java
index 7dfe9c61..bda62b41 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDatabaseMetaDataTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDatabaseMetaDataTest.java
@@ -36,9 +36,15 @@
import static org.mockito.Mockito.when;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
@ExtendWith(MockitoExtension.class)
+@SuppressFBWarnings(
+ value = "OBL_UNSATISFIED_OBLIGATION",
+ justification = "Tests use Mockito's when(mock.createStatement())/prepareStatement() / "
+ + "metaData.getCatalogs() stubbing pattern. Mockito's API requires invoking the method "
+ + "on the mock to register the stub, and the method's AutoCloseable return type triggers "
+ + "OBL on the bytecode call site. The values are mocks (or null during stubbing) and "
+ + "wrapping in try-with-resources at the call site does not discharge the obligation "
+ + "tracked at the stubbing line.")
class HoptimatorDatabaseMetaDataTest {
@Mock
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutorTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutorTest.java
index 960258a5..a902cccf 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutorTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlExecutorTest.java
@@ -8,7 +8,6 @@
import com.linkedin.hoptimator.jdbc.ddl.SqlPauseTrigger;
import com.linkedin.hoptimator.jdbc.ddl.SqlResumeTrigger;
import com.linkedin.hoptimator.util.DeploymentService;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.jdbc.CalciteConnection;
import org.apache.calcite.jdbc.CalcitePrepare;
import org.apache.calcite.rel.type.RelDataType;
@@ -53,8 +52,6 @@
import static org.mockito.Mockito.when;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
@ExtendWith(MockitoExtension.class)
class HoptimatorDdlExecutorTest {
@@ -1224,10 +1221,10 @@ private void addMaterializedViewToDefaultSchema(CalcitePrepare.Context context,
private void stubValidationToFail() {
// Casting to Object in the lambda forces Java's overload resolution to pick the T-object
// overload (erased to Object) rather than the Collection overload.
- mockValidationService.when(() -> ValidationService.validateOrThrow(any(Object.class)))
+ mockValidationService.when(() -> ValidationService.validateOrThrow(any(Object.class), any()))
.thenThrow(new SQLException("validation failed"));
// Also stub the Collection overload for callers that pass deployer collections.
- mockValidationService.when(() -> ValidationService.validateOrThrow(any(Collection.class)))
+ mockValidationService.when(() -> ValidationService.validateOrThrow(any(Collection.class), any()))
.thenThrow(new SQLException("validation failed"));
}
}
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtilsTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtilsTest.java
index a6fee5d9..29a11973 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtilsTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDdlUtilsTest.java
@@ -10,7 +10,6 @@
import com.linkedin.hoptimator.jdbc.ddl.SqlCreateMaterializedView;
import com.linkedin.hoptimator.jdbc.ddl.SqlCreateTable;
import com.linkedin.hoptimator.util.planner.PipelineRel;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.jdbc.CalcitePrepare;
import org.apache.calcite.jdbc.CalciteSchema;
import org.apache.calcite.rel.type.RelDataType;
@@ -45,6 +44,7 @@
import java.util.Map;
import java.util.Properties;
+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.assertInstanceOf;
@@ -62,8 +62,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class HoptimatorDdlUtilsTest {
@Mock
@@ -1607,4 +1605,96 @@ public String databaseName() {
return name;
}
}
+
+ // ---- removeTableFromSchema tests ----
+
+ @Test
+ void removeTableFromSchemaToleratesNullConnection() {
+ // Must not NPE — this overload is called from code paths that may lack a connection.
+ assertDoesNotThrow(() -> HoptimatorDdlUtils.removeTableFromSchema(null, null, null, "ANY"));
+ }
+
+ @Test
+ void removeTableFromSchemaRemovesFromRootWhenNoSchema() throws SQLException {
+ HoptimatorDriver driver = new HoptimatorDriver();
+ try (HoptimatorConnection connection =
+ (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) {
+ RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
+ RelDataType rowType = typeFactory.createStructType(Collections.emptyList(), Collections.emptyList());
+ HoptimatorDdlUtils.registerTemporaryTableInSchema(
+ connection, null, null, "ROOT_TMP", rowType, "db");
+ SchemaPlus rootSchema = connection.calciteConnection().getRootSchema();
+ assertNotNull(rootSchema.tables().get("ROOT_TMP"));
+
+ HoptimatorDdlUtils.removeTableFromSchema(connection, null, null, "ROOT_TMP");
+
+ assertEquals(null, rootSchema.tables().get("ROOT_TMP"));
+ }
+ }
+
+ @Test
+ void removeTableFromSchemaRemovesFromTierSchema() throws SQLException {
+ HoptimatorDriver driver = new HoptimatorDriver();
+ try (HoptimatorConnection connection =
+ (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) {
+ SchemaPlus rootSchema = connection.calciteConnection().getRootSchema();
+ rootSchema.add("KAFKA", new AbstractSchema());
+ RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
+ RelDataType rowType = typeFactory.createStructType(Collections.emptyList(), Collections.emptyList());
+ HoptimatorDdlUtils.registerTemporaryTableInSchema(
+ connection, null, "KAFKA", "my_topic", rowType, "kafka-db");
+ SchemaPlus tierSchema = rootSchema.subSchemas().get("KAFKA");
+ assertNotNull(tierSchema);
+ assertNotNull(tierSchema.tables().get("my_topic"));
+
+ HoptimatorDdlUtils.removeTableFromSchema(connection, null, "KAFKA", "my_topic");
+
+ assertEquals(null, tierSchema.tables().get("my_topic"));
+ }
+ }
+
+ @Test
+ void removeTableFromSchemaRemovesFromCatalogAndSchema() throws SQLException {
+ HoptimatorDriver driver = new HoptimatorDriver();
+ try (HoptimatorConnection connection =
+ (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) {
+ SchemaPlus rootSchema = connection.calciteConnection().getRootSchema();
+ SchemaPlus catalogSchema = rootSchema.add("CAT", new AbstractSchema());
+ SchemaPlus dbSchema = catalogSchema.add("DB", new AbstractSchema());
+ RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
+ RelDataType rowType = typeFactory.createStructType(Collections.emptyList(), Collections.emptyList());
+ HoptimatorDdlUtils.registerTemporaryTableInSchema(
+ connection, "CAT", "DB", "t", rowType, "mysql-db");
+ assertNotNull(dbSchema.tables().get("t"));
+
+ HoptimatorDdlUtils.removeTableFromSchema(connection, "CAT", "DB", "t");
+
+ assertEquals(null, dbSchema.tables().get("t"));
+ }
+ }
+
+ @Test
+ void removeTableFromSchemaIsNoOpWhenEntriesMissing() throws SQLException {
+ HoptimatorDriver driver = new HoptimatorDriver();
+ try (HoptimatorConnection connection =
+ (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) {
+ // Missing catalog, missing schema, missing table — each path must be silent.
+ SchemaPlus rootSchema = connection.calciteConnection().getRootSchema();
+
+ assertDoesNotThrow(() ->
+ HoptimatorDdlUtils.removeTableFromSchema(connection, "MISSING_CATALOG", "S", "t"));
+ assertDoesNotThrow(() ->
+ HoptimatorDdlUtils.removeTableFromSchema(connection, null, "MISSING_SCHEMA", "t"));
+ assertDoesNotThrow(() ->
+ HoptimatorDdlUtils.removeTableFromSchema(connection, null, null, "MISSING_TABLE"));
+
+ // None of those calls should have accidentally created the missing catalog / schema.
+ assertNull(rootSchema.subSchemas().get("MISSING_CATALOG"),
+ "missing-catalog call must not create the catalog");
+ assertNull(rootSchema.subSchemas().get("MISSING_SCHEMA"),
+ "missing-schema call must not create the schema");
+ assertNull(rootSchema.tables().get("MISSING_TABLE"),
+ "missing-table call must not create the table");
+ }
+ }
}
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDriverTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDriverTest.java
index 08a845ba..6225b702 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDriverTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/HoptimatorDriverTest.java
@@ -2,7 +2,6 @@
import com.linkedin.hoptimator.Source;
import com.linkedin.hoptimator.Validator;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.jdbc.CalciteConnection;
import org.apache.calcite.jdbc.CalcitePrepare;
import org.apache.calcite.rel.type.RelDataType;
@@ -29,8 +28,6 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Connections in tests are closed in try-with-resources or after assertions")
class HoptimatorDriverTest {
private final HoptimatorDriver driver = new HoptimatorDriver();
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/TestSqlScripts.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/TestSqlScripts.java
index 9c417e65..d735543d 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/TestSqlScripts.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/TestSqlScripts.java
@@ -13,6 +13,7 @@
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.sql.Connection;
import java.util.Collection;
import java.util.List;
@@ -76,7 +77,7 @@ private void useTestValidators() throws IOException {
public static class CreateViewValidatorProvider implements ValidatorProvider {
@Override
- public Collection validators(T obj) {
+ public Collection validators(T obj, Connection connection) {
if (obj instanceof SqlCreateView || obj instanceof SqlCreateMaterializedView) {
return List.of(new SqlCreateViewValidator());
}
@@ -88,7 +89,7 @@ static class SqlCreateViewValidator implements Validator {
static final String ERROR_MESSAGE = "Create view is not allowed in this test.";
@Override
- public void validate(Issues issues) {
+ public void validate(Issues issues, Connection connection) {
issues.error(ERROR_MESSAGE);
}
}
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidationServiceTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidationServiceTest.java
index 52383b04..af2bead6 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidationServiceTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidationServiceTest.java
@@ -103,7 +103,7 @@ void testValidateConnectionCallsWalk() throws SQLException {
void testValidateObjIssuesInvokesValidators() {
ValidatorProviderTest.enableErrors();
Validator.Issues issues = new Validator.Issues("test");
- ValidationService.validate("any-object", issues);
+ ValidationService.validate("any-object", issues, null);
assertFalse(issues.valid(),
"validate(obj, issues) must invoke validators; if forEach is removed no error fires");
assertTrue(issues.toString().contains("injected error"),
@@ -115,7 +115,7 @@ void testValidateObjIssuesInvokesValidators() {
void testValidateOrThrowSingleObjectThrowsWhenErrorRecorded() {
ValidatorProviderTest.enableErrors();
assertThrows(SQLException.class,
- () -> ValidationService.validateOrThrow("any-object"),
+ () -> ValidationService.validateOrThrow("any-object", null),
"validateOrThrow must throw when a provider records an error");
}
@@ -125,27 +125,27 @@ void testValidateOrThrowSingleObjectThrowsWhenErrorRecorded() {
@Test
void testValidateOrThrowSingleObjectPassesWhenValid() throws SQLException {
// ValidatorProviderTest is in no-error mode (reset in setUp)
- ValidationService.validateOrThrow("test-object");
+ ValidationService.validateOrThrow("test-object", null);
}
@Test
void testValidateOrThrowCollectionThrowsWhenErrorRecorded() {
ValidatorProviderTest.enableErrors();
assertThrows(SQLException.class,
- () -> ValidationService.validateOrThrow(Arrays.asList("obj1", "obj2")),
+ () -> ValidationService.validateOrThrow(Arrays.asList("obj1", "obj2"), null),
"validateOrThrow(Collection) must throw when provider records an error");
}
@Test
void testValidateOrThrowCollectionPassesWhenValid() throws SQLException {
Collection validObjects = Arrays.asList("obj1", "obj2");
- ValidationService.validateOrThrow(validObjects);
+ ValidationService.validateOrThrow(validObjects, null);
}
@Test
void testValidateOrThrowCollectionPassesWithEmptyCollection() throws SQLException {
Collection emptyCollection = Collections.emptyList();
- ValidationService.validateOrThrow(emptyCollection);
+ ValidationService.validateOrThrow(emptyCollection, null);
}
// ValidatorProviderTest is registered via META-INF/services so ServiceLoader must find it.
@@ -161,7 +161,7 @@ void testProvidersReturnsAtLeastOneRegisteredProvider() {
@Test
void testValidatorsReturnsValidatorsFromRegisteredProvider() {
ValidatorProviderTest.enableErrors();
- Collection validators = ValidationService.validators("any-object");
+ Collection validators = ValidationService.validators("any-object", null);
assertNotNull(validators);
assertFalse(validators.isEmpty(),
"validators() must return the non-empty list provided by ValidatorProviderTest");
@@ -175,14 +175,14 @@ void testProvidersReturnsCollection() {
@Test
void testValidatorsReturnsCollection() {
- Collection validators = ValidationService.validators("test-object");
+ Collection validators = ValidationService.validators("test-object", null);
assertNotNull(validators);
}
@Test
void testValidatePopulatesIssues() {
Validator.Issues issues = new Validator.Issues("test");
- ValidationService.validate("test-object", issues);
+ ValidationService.validate("test-object", issues, null);
assertNotNull(issues);
}
diff --git a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidatorProviderTest.java b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidatorProviderTest.java
index 6871cf89..9537c4fb 100644
--- a/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidatorProviderTest.java
+++ b/hoptimator-jdbc/src/test/java/com/linkedin/hoptimator/jdbc/ValidatorProviderTest.java
@@ -2,11 +2,12 @@
import com.linkedin.hoptimator.Validator;
import com.linkedin.hoptimator.ValidatorProvider;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.sql.Connection;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
/**
@@ -17,19 +18,17 @@
* Behavior is controlled by static flags so individual tests can configure
* it without needing to create anonymous subclasses in every test.
*/
-@SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD",
- justification = "Intentional: static state used to control test behavior across SPI-loaded instances")
public class ValidatorProviderTest implements ValidatorProvider {
/** When true, every call to validate() records an error into issues. */
private static final AtomicBoolean SHOULD_ERROR = new AtomicBoolean(false);
/** Tracks the most-recent object passed to validators(). */
- private static volatile Object lastSeen = null;
+ private static final AtomicReference LAST_SEEN = new AtomicReference<>();
static void reset() {
SHOULD_ERROR.set(false);
- lastSeen = null;
+ LAST_SEEN.set(null);
}
static void enableErrors() {
@@ -37,14 +36,14 @@ static void enableErrors() {
}
static Object lastSeen() {
- return lastSeen;
+ return LAST_SEEN.get();
}
@Override
- public Collection validators(T obj) {
- lastSeen = obj;
+ public Collection validators(T obj, Connection connection) {
+ LAST_SEEN.set(obj);
if (SHOULD_ERROR.get()) {
- return Collections.singletonList(issues -> issues.error("ValidatorProviderTest injected error"));
+ return Collections.singletonList((issues, conn) -> issues.error("ValidatorProviderTest injected error"));
}
return Collections.emptyList();
}
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sApi.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sApi.java
index 0f19919e..23ee79ef 100644
--- a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sApi.java
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sApi.java
@@ -154,7 +154,7 @@ public void update(T obj) throws SQLException {
final KubernetesApiResponse resp;
if (existing.isSuccess()) {
- // Ensure labels are additive.
+ // Ensure labels, annotations, and owners are additive.
Map labels = new HashMap<>();
if (existing.getObject().getMetadata().getLabels() != null) {
labels.putAll(existing.getObject().getMetadata().getLabels());
@@ -164,6 +164,15 @@ public void update(T obj) throws SQLException {
}
obj.getMetadata().setLabels(labels);
+ Map annotations = new HashMap<>();
+ if (existing.getObject().getMetadata().getAnnotations() != null) {
+ annotations.putAll(existing.getObject().getMetadata().getAnnotations());
+ }
+ if (obj.getMetadata().getAnnotations() != null) {
+ annotations.putAll(obj.getMetadata().getAnnotations());
+ }
+ obj.getMetadata().setAnnotations(annotations);
+
List owners = new LinkedList<>();
if (existing.getObject().getMetadata().getOwnerReferences() != null) {
owners.addAll(existing.getObject().getMetadata().getOwnerReferences());
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployer.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployer.java
index 60d657b9..76967411 100644
--- a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployer.java
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployer.java
@@ -2,6 +2,7 @@
import com.linkedin.hoptimator.Deployer;
import com.linkedin.hoptimator.MaterializedView;
+import com.linkedin.hoptimator.Sink;
import com.linkedin.hoptimator.Source;
import com.linkedin.hoptimator.SqlDialect;
import com.linkedin.hoptimator.util.DeploymentService;
@@ -9,6 +10,7 @@
import java.sql.SQLException;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
@@ -34,8 +36,8 @@ K8sViewDeployer createViewDeployer(MaterializedView view, K8sContext context) {
}
K8sPipelineBundle createPipelineBundle(String name, List pipelineSpecs, String sql,
- K8sContext viewContext) {
- return new K8sPipelineBundle(name, pipelineSpecs, sql, viewContext);
+ Collection sources, Sink sink, K8sContext viewContext) {
+ return new K8sPipelineBundle(name, pipelineSpecs, sql, sources, sink, viewContext);
}
@Override
@@ -45,7 +47,8 @@ public void create() throws SQLException {
List pipelineSpecs = pipelineSpecs();
V1OwnerReference viewRef = viewDeployer.createAndReference();
K8sContext viewContext = context.withOwner(viewRef);
- K8sPipelineBundle bundle = createPipelineBundle(name, pipelineSpecs, sql(), viewContext);
+ K8sPipelineBundle bundle = createPipelineBundle(name, pipelineSpecs, sql(),
+ view.pipeline().sources(), view.pipeline().sink(), viewContext);
deployers.add(bundle);
bundle.create();
}
@@ -58,7 +61,8 @@ public void update() throws SQLException {
List pipelineSpecs = pipelineSpecs();
V1OwnerReference viewRef = viewDeployer.updateAndReference();
K8sContext viewContext = context.withOwner(viewRef);
- K8sPipelineBundle bundle = createPipelineBundle(name, pipelineSpecs, sql(), viewContext);
+ K8sPipelineBundle bundle = createPipelineBundle(name, pipelineSpecs, sql(),
+ view.pipeline().sources(), view.pipeline().sink(), viewContext);
deployers.add(bundle);
bundle.update();
}
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineBundle.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineBundle.java
index e2b1606b..8fe455d2 100644
--- a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineBundle.java
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineBundle.java
@@ -2,11 +2,14 @@
import java.sql.SQLException;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import io.kubernetes.client.openapi.models.V1OwnerReference;
import com.linkedin.hoptimator.Deployer;
+import com.linkedin.hoptimator.Sink;
+import com.linkedin.hoptimator.Source;
/**
@@ -25,16 +28,22 @@ public class K8sPipelineBundle implements Deployer {
private final K8sPipelineDeployer pipelineDeployer;
private final List deployers = new ArrayList<>();
+ /**
+ * {@code sources} and {@code sink} are stamped as {@code depends-on-*}
+ * labels on the Pipeline CRD so the delete-time guard in {@link PipelineDependencyChecker}
+ * can find this pipeline by label selector.
+ */
public K8sPipelineBundle(String name, List pipelineSpecs, String sql,
- K8sContext ownerContext) {
+ Collection sources, Sink sink, K8sContext ownerContext) {
this.name = name;
this.pipelineSpecs = pipelineSpecs;
this.ownerContext = ownerContext;
- this.pipelineDeployer = createPipelineDeployer(name, pipelineSpecs, sql, ownerContext);
+ this.pipelineDeployer = createPipelineDeployer(name, pipelineSpecs, sql, sources, sink, ownerContext);
}
- K8sPipelineDeployer createPipelineDeployer(String n, List specs, String sql, K8sContext ctx) {
- return new K8sPipelineDeployer(n, specs, sql, ctx);
+ K8sPipelineDeployer createPipelineDeployer(String n, List specs, String sql,
+ Collection sources, Sink sink, K8sContext ctx) {
+ return new K8sPipelineDeployer(n, specs, sql, sources, sink, ctx);
}
K8sYamlDeployerImpl createYamlDeployerImpl(K8sContext pipelineContext, List specs) {
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDependencyValidator.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDependencyValidator.java
new file mode 100644
index 00000000..ff06a3be
--- /dev/null
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDependencyValidator.java
@@ -0,0 +1,39 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import javax.annotation.Nullable;
+
+import com.linkedin.hoptimator.Source;
+import com.linkedin.hoptimator.Validator;
+
+
+/**
+ * Pre-delete dependency check, run by the validator framework when a {@link Source} is wrapped
+ * in {@link com.linkedin.hoptimator.PendingDelete}. Delegates to the existing
+ * {@link PipelineDependencyChecker} (label-selector + annotation collision-guard + self-owner
+ * exclusion) and surfaces any blocking pipeline as a validation error.
+ */
+final class K8sPipelineDependencyValidator implements Validator {
+
+ private final Source source;
+ private final String selfOwnerKind;
+ private final String selfOwnerName;
+
+ K8sPipelineDependencyValidator(Source source, @Nullable String selfOwnerKind, @Nullable String selfOwnerName) {
+ this.source = source;
+ this.selfOwnerKind = selfOwnerKind;
+ this.selfOwnerName = selfOwnerName;
+ }
+
+ @Override
+ public void validate(Issues issues, Connection connection) {
+ try {
+ PipelineDependencyChecker.assertNoExternalDependents(
+ K8sContext.create(connection), source.database(), source.path(),
+ selfOwnerKind, selfOwnerName);
+ } catch (SQLException e) {
+ issues.error(e.getMessage());
+ }
+ }
+}
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployer.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployer.java
index 5e76bb19..0a9cfedf 100644
--- a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployer.java
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployer.java
@@ -1,33 +1,64 @@
package com.linkedin.hoptimator.k8s;
+import java.sql.SQLException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+
+import com.linkedin.hoptimator.Sink;
+import com.linkedin.hoptimator.Source;
import com.linkedin.hoptimator.k8s.models.V1alpha1Pipeline;
import com.linkedin.hoptimator.k8s.models.V1alpha1PipelineList;
import com.linkedin.hoptimator.k8s.models.V1alpha1PipelineSpec;
-import io.kubernetes.client.openapi.models.V1ObjectMeta;
-
-import java.sql.SQLException;
-import java.util.List;
-/** Deploys a Pipeline object. */
+/**
+ * Deploys a Pipeline object. Stamps {@code depends-on-*} labels and a {@code depends-on}
+ * collision-guard annotation describing which sources/sink the pipeline references, so
+ * {@link PipelineDependencyChecker} can look up dependents by label selector at delete time.
+ *
+ * {@link K8sApi#update} merges labels additively, so stale {@code depends-on-*} labels from
+ * a previous version of the pipeline's SQL can linger. Correctness is preserved by the
+ * annotation, which is rewritten in full on every update: the checker rejects any label-only
+ * match whose annotation doesn't list the target identifier. In return, we avoid the extra
+ * round trip that in-place label stripping would require.
+ */
class K8sPipelineDeployer extends K8sDeployer {
private final String name;
private final String yaml;
private final String sql;
+ private final Collection sources;
+ private final Sink sink;
- K8sPipelineDeployer(String name, List specs, String sql, K8sContext context) {
+ K8sPipelineDeployer(String name, List specs, String sql,
+ Collection sources, Sink sink, K8sContext context) {
super(context, K8sApiEndpoints.PIPELINES);
this.name = name;
this.yaml = String.join("\n---\n", specs);
this.sql = sql;
+ this.sources = sources == null ? Collections.emptyList() : sources;
+ this.sink = sink;
}
@Override
protected V1alpha1Pipeline toK8sObject() throws SQLException {
+ V1ObjectMeta meta = new V1ObjectMeta().name(name);
+ Map labels = PipelineDependencyLabels.labelsFor(sources, sink);
+ if (!labels.isEmpty()) {
+ meta.setLabels(new HashMap<>(labels));
+ Map annotations = new HashMap<>();
+ annotations.put(PipelineDependencyLabels.ANNOTATION_KEY,
+ PipelineDependencyLabels.annotationFor(sources, sink));
+ meta.setAnnotations(annotations);
+ }
return new V1alpha1Pipeline().kind(K8sApiEndpoints.PIPELINES.kind())
.apiVersion(K8sApiEndpoints.PIPELINES.apiVersion())
- .metadata(new V1ObjectMeta().name(name))
+ .metadata(meta)
.spec(new V1alpha1PipelineSpec().sql(sql).yaml(yaml));
}
}
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sValidatorProvider.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sValidatorProvider.java
new file mode 100644
index 00000000..3cc45e4b
--- /dev/null
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sValidatorProvider.java
@@ -0,0 +1,34 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.sql.Connection;
+import java.util.Collection;
+import java.util.Collections;
+
+import com.linkedin.hoptimator.PendingDelete;
+import com.linkedin.hoptimator.Source;
+import com.linkedin.hoptimator.Validator;
+import com.linkedin.hoptimator.ValidatorProvider;
+
+
+/**
+ * Returns a Kubernetes-backed dependency-guard validator for any {@link Source} wrapped in a
+ * {@link PendingDelete} — i.e. when a DROP is issued. Keying off
+ * {@code PendingDelete} (not raw {@code Source}) makes the guard explicitly delete-time:
+ * other callers of {@code ValidationService.validateOrThrow(source, connection)} won't trigger
+ * a pre-delete lookup against K8s.
+ */
+public class K8sValidatorProvider implements ValidatorProvider {
+
+ @Override
+ public Collection validators(T obj, Connection connection) {
+ if (obj instanceof PendingDelete) {
+ PendingDelete> pd = (PendingDelete>) obj;
+ Object target = pd.target();
+ if (target instanceof Source) {
+ return Collections.singletonList(
+ new K8sPipelineDependencyValidator((Source) target, pd.selfOwnerKind(), pd.selfOwnerName()));
+ }
+ }
+ return Collections.emptyList();
+ }
+}
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sViewTable.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sViewTable.java
index 401f6544..19a55611 100644
--- a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sViewTable.java
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sViewTable.java
@@ -16,6 +16,7 @@
import org.apache.calcite.schema.impl.AbstractSchema;
import org.apache.calcite.schema.impl.ViewTable;
+import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -157,7 +158,7 @@ public Schema.TableType getJdbcTableType() {
}
@Override
- public void validate(Validator.Issues issues) {
+ public void validate(Validator.Issues issues, Connection connection) {
for (Row row : rows()) {
Validator.Issues issues2 = issues.child(row.toString());
Validator.validateSubdomainName(row.NAME, issues2.child("NAME"));
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sYamlApi.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sYamlApi.java
index bc14dc6d..76fa7857 100644
--- a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sYamlApi.java
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sYamlApi.java
@@ -115,7 +115,7 @@ public void update(DynamicKubernetesObject obj) throws SQLException {
final KubernetesApiResponse resp;
if (existing.isSuccess()) {
- // Ensure labels are additive. Existing values are kept.
+ // Ensure labels, annotations are additive. Existing values are kept.
Map labels = new HashMap<>();
if (obj.getMetadata().getLabels() != null) {
labels.putAll(obj.getMetadata().getLabels());
@@ -125,6 +125,15 @@ public void update(DynamicKubernetesObject obj) throws SQLException {
}
existing.getObject().getMetadata().setLabels(labels);
+ Map annotations = new HashMap<>();
+ if (obj.getMetadata().getAnnotations() != null) {
+ annotations.putAll(obj.getMetadata().getAnnotations());
+ }
+ if (existing.getObject().getMetadata().getAnnotations() != null) {
+ annotations.putAll(existing.getObject().getMetadata().getAnnotations());
+ }
+ existing.getObject().getMetadata().setAnnotations(annotations);
+
obj.setMetadata(existing.getObject().getMetadata());
resp = context.dynamic(obj.getApiVersion(), K8sUtils.guessPlural(obj)).update(obj);
} else {
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/PipelineDependencyChecker.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/PipelineDependencyChecker.java
new file mode 100644
index 00000000..ee9a6b64
--- /dev/null
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/PipelineDependencyChecker.java
@@ -0,0 +1,116 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.openapi.models.V1OwnerReference;
+
+import com.linkedin.hoptimator.k8s.models.V1alpha1Pipeline;
+import com.linkedin.hoptimator.k8s.models.V1alpha1PipelineList;
+
+import javax.annotation.Nullable;
+
+
+/**
+ * Checks whether any Pipeline CRDs still depend on a resource a {@link com.linkedin.hoptimator.Deployer}
+ * is about to delete.
+ *
+ * The lookup is a label-selector list against the Pipeline CRD group, so it is O(matches) on
+ * the wire — not a full scan. Each candidate is then cross-checked against the
+ * {@link PipelineDependencyLabels#ANNOTATION_KEY} annotation to rule out the (rare) case of a
+ * hash collision in the label slug.
+ *
+ *
Pipelines owned (directly) by {@code (selfOwnerKind, selfOwnerName)} are excluded from the
+ * blocker list: those pipelines will be cascade-deleted alongside the parent resource, so counting
+ * them as external dependents would make composite deletes (e.g. {@code LogicalTableDeployer.delete()})
+ * impossible.
+ */
+public final class PipelineDependencyChecker {
+
+ private PipelineDependencyChecker() {
+ }
+
+ public static void assertNoExternalDependents(K8sContext context, String database,
+ List path, @Nullable String selfOwnerKind, @Nullable String selfOwnerName) throws SQLException {
+ assertNoExternalDependents(new K8sApi<>(context, K8sApiEndpoints.PIPELINES),
+ database, path, selfOwnerKind, selfOwnerName);
+ }
+
+ /** Variant that takes a pre-built {@link K8sApi} — used by tests to inject mocks. */
+ static void assertNoExternalDependents(K8sApi api,
+ String database, List path, @Nullable String selfOwnerKind,
+ @Nullable String selfOwnerName) throws SQLException {
+
+ String labelKey = PipelineDependencyLabels.labelKey(database, path);
+ String identifier = PipelineDependencyLabels.identifier(database, path);
+
+ Collection matches = api.select(labelKey);
+
+ List blockers = new ArrayList<>();
+ for (V1alpha1Pipeline p : matches) {
+ if (isSelfOwned(p, selfOwnerKind, selfOwnerName)) {
+ continue;
+ }
+ if (!annotationConfirms(p, identifier)) {
+ // Label matched but annotation doesn't — this is a slug collision or a stale label, skip it.
+ continue;
+ }
+ blockers.add(describeBlocker(p));
+ }
+
+ if (!blockers.isEmpty()) {
+ throw new SQLException(String.format(
+ "Cannot delete %s — %d active pipeline(s) depend on it: %s",
+ identifier, blockers.size(), String.join(", ", blockers)));
+ }
+ }
+
+ private static boolean isSelfOwned(V1alpha1Pipeline pipeline, @Nullable String selfOwnerKind,
+ @Nullable String selfOwnerName) {
+ if (selfOwnerKind == null || selfOwnerName == null) {
+ return false;
+ }
+ V1ObjectMeta meta = pipeline.getMetadata();
+ if (meta == null || meta.getOwnerReferences() == null) {
+ return false;
+ }
+ for (V1OwnerReference owner : meta.getOwnerReferences()) {
+ if (selfOwnerKind.equals(owner.getKind()) && selfOwnerName.equals(owner.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean annotationConfirms(V1alpha1Pipeline pipeline, String identifier) {
+ V1ObjectMeta meta = pipeline.getMetadata();
+ if (meta == null || meta.getAnnotations() == null) {
+ return true; // pre-labeling pipeline — conservatively trust the label match
+ }
+ String annotation = meta.getAnnotations().get(PipelineDependencyLabels.ANNOTATION_KEY);
+ if (annotation == null) {
+ return true; // same — no annotation to cross-check against
+ }
+ Set listed = PipelineDependencyLabels.parseAnnotation(annotation);
+ return listed.contains(identifier);
+ }
+
+ /**
+ * Builds a human-readable blocker description: the pipeline name, plus (when present) the top
+ * ownerReference's {@code kind/name} so the user knows which higher-level resource owns it.
+ */
+ private static String describeBlocker(V1alpha1Pipeline pipeline) {
+ V1ObjectMeta meta = pipeline.getMetadata();
+ String name = meta == null ? "" : meta.getName();
+ String ownerSuffix = "";
+ if (meta != null && meta.getOwnerReferences() != null && !meta.getOwnerReferences().isEmpty()) {
+ V1OwnerReference owner = meta.getOwnerReferences().get(0);
+ ownerSuffix = " (owned by " + owner.getKind() + "/" + owner.getName() + ")";
+ }
+ return name + ownerSuffix;
+ }
+}
diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/PipelineDependencyLabels.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/PipelineDependencyLabels.java
new file mode 100644
index 00000000..d9662337
--- /dev/null
+++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/PipelineDependencyLabels.java
@@ -0,0 +1,123 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import com.linkedin.hoptimator.Sink;
+import com.linkedin.hoptimator.Source;
+
+
+/**
+ * Computes the labels and annotation that encode a Pipeline CRD's dependency edges.
+ *
+ * Every source and sink a pipeline references is recorded as a label:
+ * {@code hoptimator.linkedin.com/depends-on-: "_"} where
+ * {@code } is a deterministic hash derived from {@code database + "_" + pathString}.
+ * The hash keeps label keys within Kubernetes's 63-character name limit for arbitrary paths,
+ * and lets {@code K8sApi.select} filter pipelines by dependency on the server.
+ *
+ * A collision-guard annotation ({@code ANNOTATION_KEY}) lists all logical identifiers verbatim,
+ * so the delete-time check can distinguish a real dependency match from a rare hash collision.
+ */
+public final class PipelineDependencyLabels {
+
+ static final String LABEL_PREFIX = "hoptimator.linkedin.com/depends-on-";
+ public static final String ANNOTATION_KEY = "hoptimator.linkedin.com/depends-on";
+
+ private static final int SLUG_LENGTH = 16; // 64 bits of SHA-256 → ~1 in 1.8e19 collisions
+ private static final int MAX_LABEL_VALUE = 63;
+
+ private PipelineDependencyLabels() {
+ }
+
+ /**
+ * Canonical logical identifier for a resource: {@code _}.
+ */
+ public static String identifier(String database, Iterable path) {
+ return database + "_" + String.join(".", path);
+ }
+
+ /** Hex slug derived from the full identifier; same identifier always produces the same slug. */
+ public static String slug(String database, Iterable path) {
+ byte[] digest = sha256(identifier(database, path).getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(SLUG_LENGTH);
+ for (int i = 0; i < SLUG_LENGTH / 2; i++) {
+ sb.append(String.format("%02x", digest[i]));
+ }
+ return sb.toString();
+ }
+
+ /** Label key a Pipeline carries if it depends on the given resource. */
+ public static String labelKey(String database, Iterable path) {
+ return LABEL_PREFIX + slug(database, path);
+ }
+
+ /**
+ * Labels to stamp on a Pipeline CRD — one entry per source and the sink. Both edges
+ * matter to the guard: dropping a source orphans pipelines that read from it; dropping a sink
+ * orphans pipelines that write to it.
+ *
+ * Keys are the same as {@link #labelKey}. Values are the readable identifier, truncated
+ * to 63 chars if necessary (the annotation preserves the untruncated form). Values are
+ * for debugging purposes only.
+ */
+ public static Map labelsFor(Collection sources, Sink sink) {
+ Map labels = new LinkedHashMap<>();
+ for (Source src : sources) {
+ labels.put(labelKey(src.database(), src.path()), truncate(identifier(src.database(), src.path())));
+ }
+ if (sink != null) {
+ labels.put(labelKey(sink.database(), sink.path()), truncate(identifier(sink.database(), sink.path())));
+ }
+ return labels;
+ }
+
+ /**
+ * Collision-guard annotation value — comma-separated list of full source and sink identifiers,
+ * deduplicated and not truncated. The delete-time check cross-references this annotation after
+ * the label selector narrows the candidate set.
+ */
+ public static String annotationFor(Collection sources, Sink sink) {
+ Set ids = new LinkedHashSet<>();
+ for (Source src : sources) {
+ ids.add(identifier(src.database(), src.path()));
+ }
+ if (sink != null) {
+ ids.add(identifier(sink.database(), sink.path()));
+ }
+ return String.join(",", ids);
+ }
+
+ /** Parses the collision-guard annotation back into the set of identifiers it encoded. */
+ public static Set parseAnnotation(String annotation) {
+ Set out = new LinkedHashSet<>();
+ if (annotation == null || annotation.isEmpty()) {
+ return out;
+ }
+ for (String id : annotation.split(",")) {
+ String trimmed = id.trim();
+ if (!trimmed.isEmpty()) {
+ out.add(trimmed);
+ }
+ }
+ return out;
+ }
+
+ private static String truncate(String value) {
+ return value.length() <= MAX_LABEL_VALUE ? value : value.substring(0, MAX_LABEL_VALUE);
+ }
+
+ private static byte[] sha256(byte[] input) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(input);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git a/hoptimator-k8s/src/main/resources/META-INF/services/com.linkedin.hoptimator.ValidatorProvider b/hoptimator-k8s/src/main/resources/META-INF/services/com.linkedin.hoptimator.ValidatorProvider
new file mode 100644
index 00000000..5fda4b5c
--- /dev/null
+++ b/hoptimator-k8s/src/main/resources/META-INF/services/com.linkedin.hoptimator.ValidatorProvider
@@ -0,0 +1 @@
+com.linkedin.hoptimator.k8s.K8sValidatorProvider
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiErrorResponseTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiErrorResponseTest.java
index 93482eb9..ff9cd7f9 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiErrorResponseTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiErrorResponseTest.java
@@ -2,7 +2,6 @@
import com.linkedin.hoptimator.k8s.models.V1alpha1Pipeline;
import com.linkedin.hoptimator.k8s.models.V1alpha1PipelineList;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.util.generic.GenericKubernetesApi;
@@ -24,8 +23,6 @@
import java.sql.SQLException;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mocked AutoCloseable and return values not needed in tests")
@ExtendWith(MockitoExtension.class)
class K8sApiErrorResponseTest {
@@ -81,6 +78,7 @@ void deleteThrowsWhenResponseIsErrorStatus() throws ApiException {
}
@Test
+ @SuppressWarnings("unchecked")
void updateThrowsWhenResponseIsErrorStatusOnFinalUpdate() throws ApiException {
V1alpha1Pipeline pipeline = makePipeline("bad-pipeline", "test-ns");
V1alpha1Pipeline existing = makePipeline("bad-pipeline", "test-ns");
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiTest.java
index b5204329..f1e52c26 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sApiTest.java
@@ -2,7 +2,6 @@
import com.linkedin.hoptimator.k8s.models.V1alpha1Pipeline;
import com.linkedin.hoptimator.k8s.models.V1alpha1PipelineList;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.models.V1Namespace;
import io.kubernetes.client.openapi.models.V1NamespaceList;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
@@ -39,8 +38,6 @@
import static org.mockito.Mockito.when;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mocked AutoCloseable and return values not needed in tests")
@ExtendWith(MockitoExtension.class)
class K8sApiTest {
@@ -279,6 +276,58 @@ void updateMergesLabelsFromExisting() throws SQLException {
assertEquals("new-val", mergedLabels.get("new-key"));
}
+ @Test
+ void updateMergesAnnotationsFromExisting() throws SQLException {
+ V1alpha1Pipeline pipeline = makePipeline("existing", "test-ns");
+ Map newAnnotations = new HashMap<>();
+ newAnnotations.put("new-key", "new-val");
+ pipeline.getMetadata().setAnnotations(newAnnotations);
+
+ V1alpha1Pipeline existing = makePipeline("existing", "test-ns");
+ Map existingAnnotations = new HashMap<>();
+ existingAnnotations.put("existing-key", "existing-val");
+ existing.getMetadata().setAnnotations(existingAnnotations);
+ existing.getMetadata().setResourceVersion("rv2");
+
+ when(mockGenericApi.get(eq("test-ns"), eq("existing"))).thenReturn(mockSingleResponse);
+ when(mockSingleResponse.isSuccess()).thenReturn(true);
+ when(mockSingleResponse.getObject()).thenReturn(existing);
+ when(mockGenericApi.update(any(V1alpha1Pipeline.class))).thenReturn(mockSingleResponse);
+
+ api.update(pipeline);
+
+ Map mergedAnnotations = pipeline.getMetadata().getAnnotations();
+ assertEquals("existing-val", mergedAnnotations.get("existing-key"));
+ assertEquals("new-val", mergedAnnotations.get("new-key"));
+ }
+
+ @Test
+ void updateLocalAnnotationWinsOnSharedKey() throws SQLException {
+ // Locks in the freshness guarantee the dependency-guard relies on: when the local object
+ // sets the same annotation key the cluster's existing object had, the local value wins.
+ // Without this, the depends-on annotation would never refresh on CREATE OR REPLACE and stale
+ // labels could no longer be disambiguated by the collision-guard.
+ V1alpha1Pipeline pipeline = makePipeline("existing", "test-ns");
+ Map newAnnotations = new HashMap<>();
+ newAnnotations.put("shared-key", "fresh-val");
+ pipeline.getMetadata().setAnnotations(newAnnotations);
+
+ V1alpha1Pipeline existing = makePipeline("existing", "test-ns");
+ Map existingAnnotations = new HashMap<>();
+ existingAnnotations.put("shared-key", "stale-val");
+ existing.getMetadata().setAnnotations(existingAnnotations);
+ existing.getMetadata().setResourceVersion("rv2");
+
+ when(mockGenericApi.get(eq("test-ns"), eq("existing"))).thenReturn(mockSingleResponse);
+ when(mockSingleResponse.isSuccess()).thenReturn(true);
+ when(mockSingleResponse.getObject()).thenReturn(existing);
+ when(mockGenericApi.update(any(V1alpha1Pipeline.class))).thenReturn(mockSingleResponse);
+
+ api.update(pipeline);
+
+ assertEquals("fresh-val", pipeline.getMetadata().getAnnotations().get("shared-key"));
+ }
+
@Test
void updateWhenObjectNotExistsCallsCreate() throws SQLException {
V1alpha1Pipeline pipeline = makePipeline("new-pipeline", "test-ns");
@@ -416,8 +465,6 @@ void updateMergesOwnerReferencesFromExisting() throws SQLException {
@Nested
@ExtendWith(MockitoExtension.class)
- @SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mocked AutoCloseable and return values not needed in tests")
class ClusterScopedEndpointTests {
@Mock
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sCatalogTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sCatalogTest.java
index e64bb7a4..00b8c4da 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sCatalogTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sCatalogTest.java
@@ -1,7 +1,6 @@
package com.linkedin.hoptimator.k8s;
import com.linkedin.hoptimator.jdbc.HoptimatorConnection;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.jdbc.CalciteSchema;
import org.apache.calcite.schema.SchemaPlus;
import org.junit.jupiter.api.Test;
@@ -23,8 +22,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class K8sCatalogTest {
@Mock
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sContextTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sContextTest.java
index d63aac76..1a8970ef 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sContextTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sContextTest.java
@@ -1,7 +1,6 @@
package com.linkedin.hoptimator.k8s;
import com.linkedin.hoptimator.jdbc.HoptimatorConnection;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.informer.SharedInformerFactory;
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
@@ -24,13 +23,11 @@
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mockito doReturn().when() stubs — framework captures the return value")
class K8sContextTest {
@Mock
@@ -258,7 +255,7 @@ void createWithPasswordAuthentication() {
props.setProperty(K8sContext.SERVER_KEY, "https://k8s.example.com");
props.setProperty(K8sContext.USER_KEY, "admin");
props.setProperty(K8sContext.PASSWORD_KEY, "secret");
- doReturn(props).when(mockConn).connectionProperties();
+ when(mockConn.connectionProperties()).thenReturn(props);
K8sContext ctx = K8sContext.create(mockConn);
@@ -274,7 +271,7 @@ void createWithTokenAuthentication() {
props.setProperty(K8sContext.NAMESPACE_KEY, "token-ns");
props.setProperty(K8sContext.SERVER_KEY, "https://k8s.example.com");
props.setProperty(K8sContext.TOKEN_KEY, "my-token");
- doReturn(props).when(mockConn).connectionProperties();
+ when(mockConn.connectionProperties()).thenReturn(props);
K8sContext ctx = K8sContext.create(mockConn);
@@ -293,7 +290,7 @@ void createWithImpersonation() {
props.setProperty(K8sContext.IMPERSONATE_USER_KEY, "impuser");
props.setProperty(K8sContext.IMPERSONATE_GROUP_KEY, "impgroup");
props.setProperty(K8sContext.IMPERSONATE_GROUPS_KEY, "group1,group2");
- doReturn(props).when(mockConn).connectionProperties();
+ when(mockConn.connectionProperties()).thenReturn(props);
K8sContext ctx = K8sContext.create(mockConn);
@@ -311,7 +308,7 @@ void createWithWatchNamespace() {
props.setProperty(K8sContext.WATCH_NAMESPACE_KEY, "watch-ns");
props.setProperty(K8sContext.SERVER_KEY, "https://k8s.example.com");
props.setProperty(K8sContext.TOKEN_KEY, "token");
- doReturn(props).when(mockConn).connectionProperties();
+ when(mockConn.connectionProperties()).thenReturn(props);
K8sContext ctx = K8sContext.create(mockConn);
@@ -325,7 +322,7 @@ void createWithNullWatchNamespaceDefaultsToEmpty() {
props.setProperty(K8sContext.NAMESPACE_KEY, "ns");
props.setProperty(K8sContext.SERVER_KEY, "https://k8s.example.com");
props.setProperty(K8sContext.TOKEN_KEY, "token");
- doReturn(props).when(mockConn).connectionProperties();
+ when(mockConn.connectionProperties()).thenReturn(props);
K8sContext ctx = K8sContext.create(mockConn);
@@ -344,7 +341,7 @@ void getPodNamespaceReturnsSelfPodNamespaceSystemProperty() {
// No NAMESPACE_KEY - will fall through to getPodNamespace()
props.setProperty(K8sContext.SERVER_KEY, "https://k8s.example.com");
props.setProperty(K8sContext.TOKEN_KEY, "token");
- doReturn(props).when(mockConn).connectionProperties();
+ when(mockConn.connectionProperties()).thenReturn(props);
K8sContext ctx = K8sContext.create(mockConn);
assertEquals("my-pod-namespace", ctx.namespace());
@@ -368,7 +365,7 @@ void getPodNamespaceReturnsDefaultWhenNeitherEnvNorPropertySet() {
Properties props = new Properties();
props.setProperty(K8sContext.SERVER_KEY, "https://k8s.example.com");
props.setProperty(K8sContext.TOKEN_KEY, "token");
- doReturn(props).when(mockConn).connectionProperties();
+ when(mockConn.connectionProperties()).thenReturn(props);
K8sContext ctx = K8sContext.create(mockConn);
// Should use DEFAULT_NAMESPACE when no env var or property is set
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDatabaseTableTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDatabaseTableTest.java
index 22469615..5a12c1d5 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDatabaseTableTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDatabaseTableTest.java
@@ -5,7 +5,6 @@
import com.linkedin.hoptimator.k8s.models.V1alpha1DatabaseSpec;
import com.linkedin.hoptimator.util.planner.HoptimatorJdbcCatalogSchema;
import com.linkedin.hoptimator.util.planner.HoptimatorJdbcSchema;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import org.apache.calcite.jdbc.CalciteSchema;
import org.apache.calcite.schema.Schema;
@@ -38,11 +37,10 @@
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class K8sDatabaseTableTest {
@Mock
@@ -259,7 +257,7 @@ void addDatabasesWithNoCatalog() throws Exception {
lenient().when(mockEngineTable.forDatabase(anyString())).thenReturn(Collections.emptyList());
Properties connProps = new Properties();
- doReturn(connProps).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(connProps);
SchemaPlus root = CalciteSchema.createRootSchema(true).plus();
@@ -290,7 +288,7 @@ void addDatabasesWithCatalog() throws Exception {
lenient().when(mockEngineTable.forDatabase(anyString())).thenReturn(Collections.emptyList());
Properties connProps = new Properties();
- doReturn(connProps).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(connProps);
SchemaPlus root = CalciteSchema.createRootSchema(true).plus();
@@ -318,7 +316,7 @@ void addDatabasesWithNullSchemaUsesName() throws Exception {
lenient().when(mockEngineTable.forDatabase(anyString())).thenReturn(Collections.emptyList());
Properties connProps = new Properties();
- doReturn(connProps).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(connProps);
SchemaPlus root = CalciteSchema.createRootSchema(true).plus();
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sJobDeployerTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sJobDeployerTest.java
index 11c97412..023be933 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sJobDeployerTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sJobDeployerTest.java
@@ -9,7 +9,6 @@
import com.linkedin.hoptimator.k8s.models.V1alpha1JobTemplate;
import com.linkedin.hoptimator.k8s.models.V1alpha1JobTemplateList;
import com.linkedin.hoptimator.k8s.models.V1alpha1JobTemplateSpec;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -31,13 +30,10 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mockito doReturn().when() stubs — framework captures the return value")
class K8sJobDeployerTest {
@Mock
@@ -98,7 +94,7 @@ K8sSnapshot createSnapshot(K8sContext context) {
@Test
void specifyWithNoTemplatesReturnsEmpty() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(new Properties());
Sink sink = new Sink("sinkdb", Arrays.asList("schema", "sink_table"),
Collections.emptyMap());
@@ -114,7 +110,7 @@ void specifyWithNoTemplatesReturnsEmpty() throws SQLException {
@Test
void specifyRendersMatchingTemplate() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(new Properties());
templates.add(new V1alpha1JobTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -136,7 +132,7 @@ void specifyRendersMatchingTemplate() throws SQLException {
@Test
void specifyFiltersOutNonMatchingDatabases() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(new Properties());
templates.add(new V1alpha1JobTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -157,7 +153,7 @@ void specifyFiltersOutNonMatchingDatabases() throws SQLException {
@Test
void specifyWithNullDatabasesMatchesAll() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(new Properties());
templates.add(new V1alpha1JobTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -178,7 +174,7 @@ void specifyWithNullDatabasesMatchesAll() throws SQLException {
@Test
void specifyRendersTemplateVariables() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(new Properties());
templates.add(new V1alpha1JobTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -203,7 +199,7 @@ void specifyRendersTemplateVariables() throws SQLException {
@Test
void specifyLambdasReturnNonEmptyValues() throws SQLException {
// Verify each key field is non-empty.
- doReturn(new Properties()).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(new Properties());
templates.add(new V1alpha1JobTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -239,7 +235,7 @@ void specifyWithFlinkConfigPropertiesIncludesThem() throws SQLException {
// Verify that sink options ARE merged into the environment
Properties connProps = new Properties();
connProps.setProperty("flinkConfig1", "value1");
- doReturn(connProps).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(connProps);
Map sinkOptions = new HashMap<>();
sinkOptions.put("sinkOption", "sinkVal");
@@ -264,7 +260,7 @@ void specifyWithFlinkConfigPropertiesIncludesThem() throws SQLException {
@Test
void specifyConditionalRenderedTemplateNotNull() throws SQLException {
// Verify null templates are skipped
- doReturn(new Properties()).when(connection).connectionProperties();
+ when(connection.connectionProperties()).thenReturn(new Properties());
templates.add(new V1alpha1JobTemplate()
.metadata(new V1ObjectMeta().name("template1"))
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployerTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployerTest.java
index 2acb55e0..5b987339 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployerTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sMaterializedViewDeployerTest.java
@@ -8,7 +8,6 @@
import com.linkedin.hoptimator.SqlDialect;
import com.linkedin.hoptimator.ThrowingFunction;
import com.linkedin.hoptimator.util.DeploymentService;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.models.V1OwnerReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -19,6 +18,7 @@
import java.sql.SQLException;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.List;
@@ -33,8 +33,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class K8sMaterializedViewDeployerTest {
@Mock
@@ -75,7 +73,7 @@ K8sViewDeployer createViewDeployer(MaterializedView v, K8sContext ctx) {
@Override
K8sPipelineBundle createPipelineBundle(String name, List pipelineSpecs, String sql,
- K8sContext viewContext) {
+ Collection sources, Sink sink, K8sContext viewContext) {
return capturedBundle;
}
};
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineBundleTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineBundleTest.java
index f8c53f33..dc84bb62 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineBundleTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineBundleTest.java
@@ -1,6 +1,5 @@
package com.linkedin.hoptimator.k8s;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.models.V1OwnerReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -10,9 +9,13 @@
import java.sql.SQLException;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import com.linkedin.hoptimator.Sink;
+import com.linkedin.hoptimator.Source;
+
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
@@ -23,8 +26,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class K8sPipelineBundleTest {
@Mock
@@ -45,9 +46,10 @@ void setUp() {
private K8sPipelineBundle makeBundleWithMocks(String name, List pipelineSpecs) {
K8sPipelineDeployer capturedPipelineDeployer = pipelineDeployer;
K8sYamlDeployerImpl capturedYamlDeployer = yamlDeployer;
- return new K8sPipelineBundle(name, pipelineSpecs, "INSERT INTO sink SELECT * FROM source", context) {
+ return new K8sPipelineBundle(name, pipelineSpecs, "INSERT INTO sink SELECT * FROM source", Collections.emptyList(), null, context) {
@Override
- K8sPipelineDeployer createPipelineDeployer(String n, List specs, String sql, K8sContext ctx) {
+ K8sPipelineDeployer createPipelineDeployer(String n, List specs, String sql,
+ Collection sources, Sink sink, K8sContext ctx) {
return capturedPipelineDeployer;
}
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineDependencyValidatorTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineDependencyValidatorTest.java
new file mode 100644
index 00000000..ac94636d
--- /dev/null
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineDependencyValidatorTest.java
@@ -0,0 +1,119 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.linkedin.hoptimator.Source;
+import com.linkedin.hoptimator.Validator;
+
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.nullable;
+
+
+/**
+ * Unit tests for {@link K8sPipelineDependencyValidator}. The validator is a thin adapter that
+ * forwards to {@link PipelineDependencyChecker#assertNoExternalDependents} — these tests use
+ * {@link MockedStatic} on both K8sContext and PipelineDependencyChecker to verify that source
+ * fields and self-owner fields are forwarded correctly, and that thrown SQLException becomes a
+ * validation issue rather than propagating.
+ */
+@ExtendWith(MockitoExtension.class)
+class K8sPipelineDependencyValidatorTest {
+
+ @Mock
+ private MockedStatic contextStatic;
+
+ @Mock
+ private MockedStatic checkerStatic;
+
+ @Mock
+ private Connection connection;
+
+ @Mock
+ private K8sContext context;
+
+ private static Source source() {
+ return new Source("kafka1", List.of("KAFKA", "my-topic"), Map.of());
+ }
+
+ @Test
+ void validateForwardsSourceFieldsAndSelfOwnerToChecker() {
+ contextStatic.when(() -> K8sContext.create(connection)).thenReturn(context);
+
+ K8sPipelineDependencyValidator validator =
+ new K8sPipelineDependencyValidator(source(), "LogicalTable", "my-table");
+ Validator.Issues issues = new Validator.Issues("test");
+
+ validator.validate(issues, connection);
+
+ ArgumentCaptor dbCaptor = ArgumentCaptor.forClass(String.class);
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> pathCaptor = ArgumentCaptor.forClass(List.class);
+ ArgumentCaptor kindCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class);
+
+ checkerStatic.verify(() -> PipelineDependencyChecker.assertNoExternalDependents(
+ eq(context), dbCaptor.capture(), pathCaptor.capture(),
+ kindCaptor.capture(), nameCaptor.capture()));
+
+ assertEquals("kafka1", dbCaptor.getValue());
+ assertEquals(List.of("KAFKA", "my-topic"), pathCaptor.getValue());
+ assertEquals("LogicalTable", kindCaptor.getValue());
+ assertEquals("my-table", nameCaptor.getValue());
+ assertTrue(issues.valid());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void validatePassesNullSelfOwnerWhenUnset() {
+ contextStatic.when(() -> K8sContext.create(connection)).thenReturn(context);
+
+ K8sPipelineDependencyValidator validator =
+ new K8sPipelineDependencyValidator(source(), null, null);
+ Validator.Issues issues = new Validator.Issues("test");
+
+ validator.validate(issues, connection);
+
+ ArgumentCaptor kindCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class);
+ checkerStatic.verify(() -> PipelineDependencyChecker.assertNoExternalDependents(
+ eq(context), nullable(String.class), nullable(List.class),
+ kindCaptor.capture(), nameCaptor.capture()));
+
+ assertNull(kindCaptor.getValue());
+ assertNull(nameCaptor.getValue());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void validateRecordsCheckerSqlExceptionAsIssue() {
+ contextStatic.when(() -> K8sContext.create(connection)).thenReturn(context);
+ checkerStatic.when(() -> PipelineDependencyChecker.assertNoExternalDependents(
+ nullable(K8sContext.class), nullable(String.class), nullable(List.class),
+ nullable(String.class), nullable(String.class)))
+ .thenThrow(new SQLException("3 active pipeline(s) depend on it: p1, p2, p3"));
+
+ K8sPipelineDependencyValidator validator =
+ new K8sPipelineDependencyValidator(source(), null, null);
+ Validator.Issues issues = new Validator.Issues("test");
+
+ validator.validate(issues, connection);
+
+ assertFalse(issues.valid(), "blocking pipelines should surface as a validation error");
+ assertTrue(issues.toString().contains("3 active pipeline"),
+ "issue message should include the SQLException's text: " + issues);
+ }
+}
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployerTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployerTest.java
index c7ef7469..a18122ed 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployerTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sPipelineDeployerTest.java
@@ -1,22 +1,35 @@
package com.linkedin.hoptimator.k8s;
+import com.linkedin.hoptimator.Sink;
+import com.linkedin.hoptimator.Source;
import com.linkedin.hoptimator.k8s.models.V1alpha1Pipeline;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
+import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class K8sPipelineDeployerTest {
+ private static Source src(String db, String path) {
+ return new Source(db, Collections.singletonList(path), Collections.emptyMap());
+ }
+
+ private static Sink sink(String db, String path) {
+ return new Sink(db, Collections.singletonList(path), Collections.emptyMap());
+ }
+
@Test
void toK8sObjectSetsPipelineFields() throws SQLException {
- K8sPipelineDeployer deployer = new K8sPipelineDeployer(
- "my-pipeline", Arrays.asList("spec1", "spec2"), "SELECT 1", null);
+ K8sPipelineDeployer deployer = new K8sPipelineDeployer("my-pipeline", Arrays.asList("spec1", "spec2"),
+ "SELECT 1", Collections.emptyList(), null, null);
V1alpha1Pipeline pipeline = deployer.toK8sObject();
@@ -29,11 +42,46 @@ void toK8sObjectSetsPipelineFields() throws SQLException {
@Test
void toK8sObjectWithSingleSpec() throws SQLException {
- K8sPipelineDeployer deployer = new K8sPipelineDeployer(
- "single", List.of("only-spec"), "SELECT 1", null);
+ K8sPipelineDeployer deployer = new K8sPipelineDeployer("single", List.of("only-spec"),
+ "SELECT 1", Collections.emptyList(), null, null);
V1alpha1Pipeline pipeline = deployer.toK8sObject();
assertEquals("only-spec", pipeline.getSpec().getYaml());
}
+
+ @Test
+ void stampsDependencyLabelsForSourcesAndSink() throws SQLException {
+ K8sPipelineDeployer deployer = new K8sPipelineDeployer(
+ "p1", List.of("spec"), "SELECT 1",
+ Arrays.asList(src("kafka1", "topic-a"), src("kafka2", "topic-b")),
+ sink("mysql", "outbox"), null);
+
+ V1alpha1Pipeline pipeline = deployer.toK8sObject();
+ Map labels = pipeline.getMetadata().getLabels();
+
+ assertEquals(3, labels.size(), "should have one label per source + one for the sink");
+ assertTrue(labels.containsKey(
+ PipelineDependencyLabels.labelKey("kafka1", Collections.singletonList("topic-a"))));
+ assertTrue(labels.containsKey(
+ PipelineDependencyLabels.labelKey("kafka2", Collections.singletonList("topic-b"))));
+ assertTrue(labels.containsKey(
+ PipelineDependencyLabels.labelKey("mysql", Collections.singletonList("outbox"))));
+ }
+
+ @Test
+ void stampsCollisionGuardAnnotation() throws SQLException {
+ K8sPipelineDeployer deployer = new K8sPipelineDeployer(
+ "p1", List.of("spec"), "SELECT 1",
+ Collections.singletonList(src("kafka", "topic")),
+ sink("mysql", "outbox"), null);
+
+ V1alpha1Pipeline pipeline = deployer.toK8sObject();
+ Map annotations = pipeline.getMetadata().getAnnotations();
+
+ String annotation = annotations.get(PipelineDependencyLabels.ANNOTATION_KEY);
+ assertNotNull(annotation);
+ assertTrue(annotation.contains("kafka_topic"));
+ assertTrue(annotation.contains("mysql_outbox"));
+ }
}
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sSourceDeployerTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sSourceDeployerTest.java
index 4535a359..9bc94560 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sSourceDeployerTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sSourceDeployerTest.java
@@ -5,7 +5,6 @@
import com.linkedin.hoptimator.k8s.models.V1alpha1TableTemplate;
import com.linkedin.hoptimator.k8s.models.V1alpha1TableTemplateList;
import com.linkedin.hoptimator.k8s.models.V1alpha1TableTemplateSpec;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -26,13 +25,10 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mockito doReturn().when() stubs — framework captures the return value")
class K8sSourceDeployerTest {
@Mock
@@ -58,7 +54,12 @@ K8sYamlApi createYamlApi(K8sContext context) {
return fakeYamlApi;
}
};
+ }
+
+ /** Wires up {@code mockContext.connection()} for tests that exercise specify()'s template path. */
+ private void stubConnection() {
when(mockContext.connection()).thenReturn(connection);
+ when(connection.connectionProperties()).thenReturn(new Properties());
}
private K8sSourceDeployer makeDeployer(Source source) {
@@ -85,7 +86,7 @@ K8sSnapshot createSnapshot(K8sContext context) {
@Test
void specifyWithNoTemplatesReturnsEmpty() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
Source source = new Source("testdb", Arrays.asList("schema", "table"),
Collections.emptyMap());
@@ -100,7 +101,7 @@ void specifyWithNoTemplatesReturnsEmpty() throws SQLException {
@Test
void specifyRendersMatchingTemplate() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
templates.add(new V1alpha1TableTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -121,7 +122,7 @@ void specifyRendersMatchingTemplate() throws SQLException {
@Test
void specifyFiltersOutNonMatchingDatabases() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
templates.add(new V1alpha1TableTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -141,7 +142,7 @@ void specifyFiltersOutNonMatchingDatabases() throws SQLException {
@Test
void specifyWithJobPropertiesInOptions() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
templates.add(new V1alpha1TableTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -161,7 +162,7 @@ void specifyWithJobPropertiesInOptions() throws SQLException {
@Test
void specifyWithNullDatabasesMatchesAll() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
templates.add(new V1alpha1TableTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -182,7 +183,7 @@ void specifyWithNullDatabasesMatchesAll() throws SQLException {
@Test
void specifyRendersNonEmptyYamlWithSourceContent() throws SQLException {
// Verify fields are non-empty.
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
templates.add(new V1alpha1TableTemplate()
.metadata(new V1ObjectMeta().name("template1"))
@@ -206,7 +207,7 @@ void specifyRendersNonEmptyYamlWithSourceContent() throws SQLException {
@Test
void getJobPropertiesFromOptionsMapsCorrectKeys() throws SQLException {
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
// Template uses {{job.properties}} prefix variable to expose job properties
templates.add(new V1alpha1TableTemplate()
@@ -233,7 +234,7 @@ void getJobPropertiesFromOptionsMapsCorrectKeys() throws SQLException {
@Test
void getJobPropertiesFromOptionsFiltersNonMatchingKeys() throws SQLException {
// Ensures the filter actually filters — only job.properties.* keys should be mapped
- doReturn(new Properties()).when(connection).connectionProperties();
+ stubConnection();
templates.add(new V1alpha1TableTemplate()
.metadata(new V1ObjectMeta().name("template1"))
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sValidatorProviderTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sValidatorProviderTest.java
new file mode 100644
index 00000000..a4cf0189
--- /dev/null
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sValidatorProviderTest.java
@@ -0,0 +1,77 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import com.linkedin.hoptimator.PendingDelete;
+import com.linkedin.hoptimator.Source;
+import com.linkedin.hoptimator.Validator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+class K8sValidatorProviderTest {
+
+ private static Source testSource() {
+ return new Source("kafka1", java.util.List.of("KAFKA", "my-topic"), Map.of());
+ }
+
+ @Test
+ void returnsDependencyValidatorForPendingDeleteOfSource() {
+ K8sValidatorProvider provider = new K8sValidatorProvider();
+ PendingDelete pd = new PendingDelete<>(testSource());
+
+ Collection validators = provider.validators(pd, null);
+
+ assertEquals(1, validators.size());
+ assertInstanceOf(K8sPipelineDependencyValidator.class, validators.iterator().next());
+ }
+
+ @Test
+ void returnsDependencyValidatorWhenSelfOwnerIsSet() {
+ // Self-owner fields are stored on the validator; this exercises the (kind, name) plumbing
+ // through the provider boundary.
+ K8sValidatorProvider provider = new K8sValidatorProvider();
+ PendingDelete pd = new PendingDelete<>(testSource(), "LogicalTable", "my-table");
+
+ Collection validators = provider.validators(pd, null);
+
+ assertEquals(1, validators.size());
+ assertInstanceOf(K8sPipelineDependencyValidator.class, validators.iterator().next());
+ }
+
+ @Test
+ void returnsEmptyForRawSourceWithoutPendingDeleteWrapper() {
+ // The validator only fires for a delete-intent signal — not for a plain Source. Other callers
+ // of ValidationService.validate(source, ...) must NOT trigger the K8s pipeline lookup.
+ K8sValidatorProvider provider = new K8sValidatorProvider();
+
+ Collection validators = provider.validators(testSource(), null);
+
+ assertTrue(validators.isEmpty());
+ }
+
+ @Test
+ void returnsEmptyForPendingDeleteOfNonSourceTarget() {
+ K8sValidatorProvider provider = new K8sValidatorProvider();
+ PendingDelete pd = new PendingDelete<>("not-a-source");
+
+ Collection validators = provider.validators(pd, null);
+
+ assertTrue(validators.isEmpty());
+ }
+
+ @Test
+ void returnsEmptyForUnrelatedTypes() {
+ K8sValidatorProvider provider = new K8sValidatorProvider();
+
+ assertTrue(provider.validators("just-a-string", null).isEmpty());
+ assertTrue(provider.validators(42, null).isEmpty());
+ assertTrue(provider.validators(Collections.emptyList(), null).isEmpty());
+ }
+}
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sViewTableTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sViewTableTest.java
index a7a50130..8e817d15 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sViewTableTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sViewTableTest.java
@@ -198,7 +198,7 @@ void validateWithValidRows() {
stubRows();
Validator.Issues issues = new Validator.Issues("test");
- tableWithApi.validate(issues);
+ tableWithApi.validate(issues, null);
assertNotNull(issues);
}
@@ -213,7 +213,7 @@ void validateDetectsDuplicateNames() {
stubRows();
Validator.Issues issues = new Validator.Issues("test");
- tableWithApi.validate(issues);
+ tableWithApi.validate(issues, null);
assertNotNull(issues);
}
@@ -402,7 +402,7 @@ void validateWithDuplicateNameRecordsError() {
stubRows();
Validator.Issues issues = new Validator.Issues("test");
- tableWithApi.validate(issues);
+ tableWithApi.validate(issues, null);
assertFalse(issues.valid(),
"Duplicate view name must record an error, making issues invalid");
@@ -419,7 +419,7 @@ void validateWithUniqueValidNamesIsValid() {
stubRows();
Validator.Issues issues = new Validator.Issues("test");
- tableWithApi.validate(issues);
+ tableWithApi.validate(issues, null);
assertTrue(issues.valid(), "unique valid names must not produce errors");
}
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sYamlApiTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sYamlApiTest.java
index ec4a4692..d640d7da 100644
--- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sYamlApiTest.java
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sYamlApiTest.java
@@ -1,6 +1,5 @@
package com.linkedin.hoptimator.k8s;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1OwnerReference;
@@ -86,9 +85,7 @@ void constructorAcceptsContext() {
@Nested
@ExtendWith(MockitoExtension.class)
- @SuppressFBWarnings(value = {"RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mockito doReturn().when() stubs — framework captures the return value")
- class RealMethodTests {
+ class RealMethodTests {
@Mock
private K8sContext mockContext;
@@ -96,17 +93,17 @@ class RealMethodTests {
@SuppressWarnings("unchecked")
private KubernetesApiResponse successResponse(DynamicKubernetesObject obj) {
KubernetesApiResponse resp = mock(KubernetesApiResponse.class);
- lenient().doReturn(true).when(resp).isSuccess();
- lenient().doReturn(200).when(resp).getHttpStatusCode();
- lenient().doReturn(obj).when(resp).getObject();
+ lenient().when(resp.isSuccess()).thenReturn(true);
+ lenient().when(resp.getHttpStatusCode()).thenReturn(200);
+ lenient().when(resp.getObject()).thenReturn(obj);
return resp;
}
@SuppressWarnings("unchecked")
private KubernetesApiResponse notFoundResponse() {
KubernetesApiResponse resp = mock(KubernetesApiResponse.class);
- lenient().doReturn(false).when(resp).isSuccess();
- lenient().doReturn(404).when(resp).getHttpStatusCode();
+ lenient().when(resp.isSuccess()).thenReturn(false);
+ lenient().when(resp.getHttpStatusCode()).thenReturn(404);
return resp;
}
@@ -419,9 +416,7 @@ void fakeCreateWithMetadataAllNulls() throws SQLException {
@Nested
@ExtendWith(MockitoExtension.class)
- @SuppressFBWarnings(value = {"RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mockito doReturn().when() stubs — framework captures the return value")
- class CreateWithMetadataSideEffectTests {
+ class CreateWithMetadataSideEffectTests {
@Mock
private K8sContext mockContext;
@@ -429,9 +424,9 @@ class CreateWithMetadataSideEffectTests {
@SuppressWarnings("unchecked")
private KubernetesApiResponse successResponse(DynamicKubernetesObject obj) {
KubernetesApiResponse resp = mock(KubernetesApiResponse.class);
- lenient().doReturn(true).when(resp).isSuccess();
- lenient().doReturn(200).when(resp).getHttpStatusCode();
- lenient().doReturn(obj).when(resp).getObject();
+ lenient().when(resp.isSuccess()).thenReturn(true);
+ lenient().when(resp.getHttpStatusCode()).thenReturn(200);
+ lenient().when(resp.getObject()).thenReturn(obj);
return resp;
}
@@ -616,9 +611,7 @@ void createWithMetadataMergesExistingOwnerRefsWithProvided() throws SQLException
@Nested
@ExtendWith(MockitoExtension.class)
- @SuppressFBWarnings(value = {"RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"},
- justification = "Mockito doReturn().when() stubs — framework captures the return value")
- class UpdateSideEffectTests {
+ class UpdateSideEffectTests {
@Mock
private K8sContext mockContext;
@@ -626,17 +619,17 @@ class UpdateSideEffectTests {
@SuppressWarnings("unchecked")
private KubernetesApiResponse successResponse(DynamicKubernetesObject obj) {
KubernetesApiResponse resp = mock(KubernetesApiResponse.class);
- lenient().doReturn(true).when(resp).isSuccess();
- lenient().doReturn(200).when(resp).getHttpStatusCode();
- lenient().doReturn(obj).when(resp).getObject();
+ lenient().when(resp.isSuccess()).thenReturn(true);
+ lenient().when(resp.getHttpStatusCode()).thenReturn(200);
+ lenient().when(resp.getObject()).thenReturn(obj);
return resp;
}
@SuppressWarnings("unchecked")
private KubernetesApiResponse notFoundResponse() {
KubernetesApiResponse resp = mock(KubernetesApiResponse.class);
- lenient().doReturn(false).when(resp).isSuccess();
- lenient().doReturn(404).when(resp).getHttpStatusCode();
+ lenient().when(resp.isSuccess()).thenReturn(false);
+ lenient().when(resp.getHttpStatusCode()).thenReturn(404);
return resp;
}
@@ -780,8 +773,8 @@ void getIfExistsReturnsObjectFor200AndNotNullFor200Status() throws SQLException
void getIfExistsThrowsForNon404ErrorResponse() throws ApiException, SQLException {
@SuppressWarnings("unchecked")
KubernetesApiResponse errorResp = mock(KubernetesApiResponse.class);
- lenient().doReturn(false).when(errorResp).isSuccess();
- lenient().doReturn(500).when(errorResp).getHttpStatusCode();
+ lenient().when(errorResp.isSuccess()).thenReturn(false);
+ lenient().when(errorResp.getHttpStatusCode()).thenReturn(500);
ApiException apiEx = new ApiException(500, "Server Error");
doThrow(apiEx).when(errorResp).throwsApiException();
@@ -856,6 +849,75 @@ void updateExistingObjectWithNewLabelsOnlyPreservesAll() throws SQLException {
assertNotNull(capturedLabels);
assertTrue(capturedLabels.containsKey("existing"), "Should contain existing label");
}
+
+ @Test
+ void updateExistingPreservesExistingAnnotationsOverNewOnes() throws SQLException {
+ // obj.setMetadata(existing.getObject().getMetadata()) — existing metadata replaces obj's.
+ // After update, existing labels are present on the object passed to dynApi.update().
+ DynamicKubernetesObject existingObj = new DynamicKubernetesObject();
+ existingObj.setApiVersion("v1");
+ existingObj.setKind("ConfigMap");
+ Map existingAnnotations = new HashMap<>();
+ existingAnnotations.put("key", "existing-value");
+ existingObj.setMetadata(new V1ObjectMeta().name("target").namespace("ns").annotations(existingAnnotations));
+
+ DynamicKubernetesApi dynApi = mock(DynamicKubernetesApi.class);
+ doReturn(dynApi).when(mockContext).dynamic(anyString(), anyString());
+ doReturn(successResponse(existingObj)).when(dynApi).get(anyString(), anyString());
+ doReturn(successResponse(existingObj)).when(dynApi).update(any(DynamicKubernetesObject.class));
+
+ K8sYamlApi api = new K8sYamlApi(mockContext);
+ DynamicKubernetesObject obj = new DynamicKubernetesObject();
+ obj.setApiVersion("v1");
+ obj.setKind("ConfigMap");
+ // obj starts with different annotation value for same key
+ Map newAnnotations = new HashMap<>();
+ newAnnotations.put("key", "new-value");
+ obj.setMetadata(new V1ObjectMeta().name("target").namespace("ns").annotations(newAnnotations));
+
+ api.update(obj);
+
+ // After update, obj's metadata = existingObj's metadata
+ // The existing label value should be present since existing metadata replaces obj's metadata
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DynamicKubernetesObject.class);
+ verify(dynApi, times(1)).update(captor.capture());
+ Map capturedAnnotations = captor.getValue().getMetadata().getAnnotations();
+ assertNotNull(capturedAnnotations);
+ assertEquals("existing-value", capturedAnnotations.get("key"));
+ }
+
+ @Test
+ void updateExistingWithDisjointAnnotationsKeepsExisting() throws SQLException {
+ // Mirrors updateExistingObjectWithNewLabelsOnlyPreservesAll for annotations: when local and
+ // existing carry different keys, existing's annotation ends up on the captured update payload.
+ DynamicKubernetesObject existingObj = new DynamicKubernetesObject();
+ existingObj.setApiVersion("v1");
+ existingObj.setKind("ConfigMap");
+ Map existingAnnotations = new HashMap<>();
+ existingAnnotations.put("from-cluster", "e-val");
+ existingObj.setMetadata(new V1ObjectMeta().name("target").namespace("ns").annotations(existingAnnotations));
+
+ DynamicKubernetesApi dynApi = mock(DynamicKubernetesApi.class);
+ doReturn(dynApi).when(mockContext).dynamic(anyString(), anyString());
+ doReturn(successResponse(existingObj)).when(dynApi).get(anyString(), anyString());
+ doReturn(successResponse(existingObj)).when(dynApi).update(any(DynamicKubernetesObject.class));
+
+ K8sYamlApi api = new K8sYamlApi(mockContext);
+ DynamicKubernetesObject obj = new DynamicKubernetesObject();
+ obj.setApiVersion("v1");
+ obj.setKind("ConfigMap");
+ Map newAnnotations = new HashMap<>();
+ newAnnotations.put("from-local", "a-val");
+ obj.setMetadata(new V1ObjectMeta().name("target").namespace("ns").annotations(newAnnotations));
+
+ api.update(obj);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(DynamicKubernetesObject.class);
+ verify(dynApi, times(1)).update(captor.capture());
+ Map capturedAnnotations = captor.getValue().getMetadata().getAnnotations();
+ assertNotNull(capturedAnnotations);
+ assertTrue(capturedAnnotations.containsKey("from-cluster"), "Should contain existing annotation");
+ }
}
private K8sYamlApi createRealApi() {
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/PipelineDependencyCheckerTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/PipelineDependencyCheckerTest.java
new file mode 100644
index 00000000..25cf6a40
--- /dev/null
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/PipelineDependencyCheckerTest.java
@@ -0,0 +1,140 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.openapi.models.V1OwnerReference;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.linkedin.hoptimator.k8s.models.V1alpha1Pipeline;
+import com.linkedin.hoptimator.k8s.models.V1alpha1PipelineList;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+
+@ExtendWith(MockitoExtension.class)
+class PipelineDependencyCheckerTest {
+
+ @Mock
+ private K8sApi api;
+
+ private static final String DB = "kafka1";
+ private static final List PATH = Collections.singletonList("my-topic");
+ private static final String IDENTIFIER = "kafka1_my-topic";
+
+ private static V1alpha1Pipeline pipeline(String name, String ownerKind, String ownerName,
+ String annotationValue) {
+ V1ObjectMeta meta = new V1ObjectMeta().name(name);
+ if (ownerKind != null && ownerName != null) {
+ meta.addOwnerReferencesItem(new V1OwnerReference().kind(ownerKind).name(ownerName));
+ }
+ if (annotationValue != null) {
+ Map annotations = new HashMap<>();
+ annotations.put(PipelineDependencyLabels.ANNOTATION_KEY, annotationValue);
+ meta.setAnnotations(annotations);
+ }
+ return new V1alpha1Pipeline().metadata(meta);
+ }
+
+ @Test
+ void passesWhenNoPipelinesMatch() throws SQLException {
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH))).thenReturn(Collections.emptyList());
+
+ assertDoesNotThrow(() -> PipelineDependencyChecker.assertNoExternalDependents(api, DB, PATH, null, null));
+ }
+
+ @Test
+ void blocksOnExternalPipeline() throws SQLException {
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH)))
+ .thenReturn(Collections.singletonList(pipeline("ext-pipe", "View", "owner", IDENTIFIER)));
+
+ SQLException ex = assertThrows(SQLException.class,
+ () -> PipelineDependencyChecker.assertNoExternalDependents(api, DB, PATH, null, null));
+ assertTrue(ex.getMessage().contains("ext-pipe"));
+ assertTrue(ex.getMessage().contains(IDENTIFIER));
+ }
+
+ @Test
+ void skipsSelfOwnedPipeline() throws SQLException {
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH)))
+ .thenReturn(Collections.singletonList(pipeline("owned-pipe", "LogicalTable", "self-name", IDENTIFIER)));
+
+ assertDoesNotThrow(() -> PipelineDependencyChecker.assertNoExternalDependents(
+ api, DB, PATH, "LogicalTable", "self-name"));
+ }
+
+ @Test
+ void blocksOnExternalWhenSomeAreSelfOwned() throws SQLException {
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH))).thenReturn(Arrays.asList(
+ pipeline("owned-pipe", "LogicalTable", "self-name", IDENTIFIER),
+ pipeline("external-pipe", "View", "other-owner", IDENTIFIER)));
+
+ SQLException ex = assertThrows(SQLException.class,
+ () -> PipelineDependencyChecker.assertNoExternalDependents(
+ api, DB, PATH, "LogicalTable", "self-name"));
+ assertTrue(ex.getMessage().contains("external-pipe"));
+ assertFalse(ex.getMessage().contains("owned-pipe"), "self-owned pipeline must not be listed");
+ }
+
+ @Test
+ void rejectsSlugCollisionViaAnnotation() throws SQLException {
+ // Pipeline labels collide on the slug (which is what api.select matched on) but the
+ // annotation reveals the actual identifier is different — so this should NOT block.
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH)))
+ .thenReturn(Collections.singletonList(pipeline("colliding-pipe", "View", "owner",
+ "some-other-database/some-other-path")));
+
+ assertDoesNotThrow(() -> PipelineDependencyChecker.assertNoExternalDependents(api, DB, PATH, null, null));
+ }
+
+ @Test
+ void treatsMissingAnnotationAsTrusted() throws SQLException {
+ // A pipeline with the matching label but no depends-on annotation (pre-labeling migration
+ // case, or future code path that didn't write the annotation) is still treated as a blocker.
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH)))
+ .thenReturn(Collections.singletonList(pipeline("legacy-pipe", "View", "owner", null)));
+
+ SQLException ex = assertThrows(SQLException.class,
+ () -> PipelineDependencyChecker.assertNoExternalDependents(api, DB, PATH, null, null));
+ assertTrue(ex.getMessage().contains("legacy-pipe"));
+ }
+
+ @Test
+ void errorMessageIncludesOwnerKindAndName() throws SQLException {
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH)))
+ .thenReturn(Collections.singletonList(pipeline("ext-pipe", "View", "owner", IDENTIFIER)));
+
+ SQLException ex = assertThrows(SQLException.class,
+ () -> PipelineDependencyChecker.assertNoExternalDependents(api, DB, PATH, null, null));
+ assertTrue(ex.getMessage().contains("View/owner"),
+ "error should name the owning View so the user knows what to unhook: " + ex.getMessage());
+ }
+
+ @Test
+ void errorMessageListsAllBlockers() throws SQLException {
+ when(api.select(PipelineDependencyLabels.labelKey(DB, PATH))).thenReturn(Arrays.asList(
+ pipeline("p1", "View", "owner1", IDENTIFIER),
+ pipeline("p2", "View", "owner2", IDENTIFIER),
+ pipeline("p3", "View", "owner3", IDENTIFIER)));
+
+ SQLException ex = assertThrows(SQLException.class,
+ () -> PipelineDependencyChecker.assertNoExternalDependents(api, DB, PATH, null, null));
+ assertTrue(ex.getMessage().contains("p1"));
+ assertTrue(ex.getMessage().contains("p2"));
+ assertTrue(ex.getMessage().contains("p3"));
+ assertTrue(ex.getMessage().contains("3 active pipeline"));
+ }
+}
diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/PipelineDependencyLabelsTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/PipelineDependencyLabelsTest.java
new file mode 100644
index 00000000..b5cd6afb
--- /dev/null
+++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/PipelineDependencyLabelsTest.java
@@ -0,0 +1,159 @@
+package com.linkedin.hoptimator.k8s;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import com.linkedin.hoptimator.Sink;
+import com.linkedin.hoptimator.Source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+class PipelineDependencyLabelsTest {
+
+ private static Source src(String db, String... path) {
+ return new Source(db, Arrays.asList(path), Collections.emptyMap());
+ }
+
+ private static Sink sink(String db, String... path) {
+ return new Sink(db, Arrays.asList(path), Collections.emptyMap());
+ }
+
+ @Test
+ void identifierJoinsDatabaseAndPath() {
+ // Separator is "_" so the identifier is also a valid K8s label value out of the box.
+ assertEquals("mydb_a.b.c", PipelineDependencyLabels.identifier("mydb", Arrays.asList("a", "b", "c")));
+ }
+
+ @Test
+ void slugIsDeterministic() {
+ String s1 = PipelineDependencyLabels.slug("db", Arrays.asList("foo", "bar"));
+ String s2 = PipelineDependencyLabels.slug("db", Arrays.asList("foo", "bar"));
+ assertEquals(s1, s2);
+ }
+
+ @Test
+ void slugVariesByDatabase() {
+ String a = PipelineDependencyLabels.slug("db1", Collections.singletonList("t"));
+ String b = PipelineDependencyLabels.slug("db2", Collections.singletonList("t"));
+ assertNotEquals(a, b);
+ }
+
+ @Test
+ void slugVariesByPath() {
+ String a = PipelineDependencyLabels.slug("db", Arrays.asList("schema", "t"));
+ String b = PipelineDependencyLabels.slug("db", Arrays.asList("schema", "u"));
+ assertNotEquals(a, b);
+ }
+
+ @Test
+ void labelKeyFitsKubernetesNameLimit() {
+ // Long path stressing the slug — name portion (after the /) must be ≤ 63 chars.
+ String key = PipelineDependencyLabels.labelKey(
+ "a-really-long-database-name",
+ Arrays.asList("catalog", "schema", "a_very_long_table_name_that_exceeds_sixty_three_chars"));
+ String namePortion = key.substring(key.indexOf('/') + 1);
+ assertTrue(namePortion.length() <= 63, "name portion must be ≤63 chars, got " + namePortion.length());
+ assertTrue(namePortion.matches("[a-z0-9]([-a-z0-9_.]*[a-z0-9])?"),
+ "name portion must match K8s label-name regex, got: " + namePortion);
+ }
+
+ @Test
+ void labelsForIncludesSourcesAndSink() {
+ // Both edges matter: dropping a source orphans readers; dropping a sink orphans writers.
+ Source s1 = src("kafka1", "events");
+ Source s2 = src("venice1", "store");
+ Sink sink = sink("mysql1", "outbox");
+ Map labels = PipelineDependencyLabels.labelsFor(Arrays.asList(s1, s2), sink);
+
+ assertEquals(3, labels.size());
+ assertTrue(labels.containsKey(PipelineDependencyLabels.labelKey("kafka1", Collections.singletonList("events"))));
+ assertTrue(labels.containsKey(PipelineDependencyLabels.labelKey("venice1", Collections.singletonList("store"))));
+ assertTrue(labels.containsKey(PipelineDependencyLabels.labelKey("mysql1", Collections.singletonList("outbox"))));
+ }
+
+ @Test
+ void labelsForHandlesNullSink() {
+ Map labels = PipelineDependencyLabels.labelsFor(
+ Collections.singletonList(src("db", "t")), null);
+ assertEquals(1, labels.size());
+ }
+
+ @Test
+ void labelsForCollapsesSelfLoopIntoOneEntry() {
+ // Self-loop pipeline: source and sink share a slug, so the map collapses to one entry
+ // rather than producing duplicate keys.
+ Source s = src("db", "t");
+ Sink k = sink("db", "t");
+ Map labels = PipelineDependencyLabels.labelsFor(Collections.singletonList(s), k);
+ assertEquals(1, labels.size());
+ }
+
+ @Test
+ void labelValueTruncatedAtSixtyThreeChars() {
+ String longPath = "this_is_a_really_long_table_name_that_exceeds_sixty_three_chars_by_a_lot";
+ Map labels = PipelineDependencyLabels.labelsFor(
+ Collections.singletonList(src("db", longPath)), null);
+ String value = labels.values().iterator().next();
+ assertTrue(value.length() <= 63);
+ }
+
+ @Test
+ void labelValueIsKubernetesLabelValueCompliant() {
+ // K8s label values must match (([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?
+ // — the identifier separator is "_" precisely so this holds out of the box for typical
+ // (database, path) shapes seen in production.
+ Map labels = PipelineDependencyLabels.labelsFor(
+ Collections.singletonList(src("ads-database", "ADS", "PAGE_VIEWS")), null);
+ String value = labels.values().iterator().next();
+
+ assertTrue(value.length() <= 63);
+ assertTrue(value.matches("(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?"),
+ "value must satisfy K8s label-value regex, got: " + value);
+ assertFalse(value.contains("/"), "no '/' separator should leak into the label value");
+ }
+
+ @Test
+ void annotationForListsAllIdentifiers() {
+ // Sources and the sink — both edges are recorded so the delete-time check can disambiguate
+ // a real dependency from a hash collision regardless of which side matched.
+ String annotation = PipelineDependencyLabels.annotationFor(
+ Arrays.asList(src("kafka", "a"), src("venice", "b")),
+ sink("mysql", "c"));
+ assertTrue(annotation.contains("kafka_a"));
+ assertTrue(annotation.contains("venice_b"));
+ assertTrue(annotation.contains("mysql_c"));
+ }
+
+ @Test
+ void annotationForDeduplicatesAndOmitsNullSink() {
+ String annotation = PipelineDependencyLabels.annotationFor(
+ Arrays.asList(src("db", "t"), src("db", "t")), null);
+ assertEquals("db_t", annotation);
+ }
+
+ @Test
+ void parseAnnotationRoundtrip() {
+ String annotation = PipelineDependencyLabels.annotationFor(
+ Arrays.asList(src("a", "1"), src("b", "2")), sink("c", "3"));
+ Set parsed = PipelineDependencyLabels.parseAnnotation(annotation);
+ assertEquals(3, parsed.size());
+ assertTrue(parsed.contains("a_1"));
+ assertTrue(parsed.contains("b_2"));
+ assertTrue(parsed.contains("c_3"));
+ }
+
+ @Test
+ void parseAnnotationHandlesNullAndEmpty() {
+ assertTrue(PipelineDependencyLabels.parseAnnotation(null).isEmpty());
+ assertTrue(PipelineDependencyLabels.parseAnnotation("").isEmpty());
+ assertTrue(PipelineDependencyLabels.parseAnnotation(" , ").isEmpty());
+ }
+}
diff --git a/hoptimator-kafka/src/main/java/com/linkedin/hoptimator/kafka/KafkaDeployer.java b/hoptimator-kafka/src/main/java/com/linkedin/hoptimator/kafka/KafkaDeployer.java
index 6b9e8fc5..3a269d1e 100644
--- a/hoptimator-kafka/src/main/java/com/linkedin/hoptimator/kafka/KafkaDeployer.java
+++ b/hoptimator-kafka/src/main/java/com/linkedin/hoptimator/kafka/KafkaDeployer.java
@@ -19,6 +19,7 @@
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
+import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration;
import java.util.Collections;
@@ -53,7 +54,7 @@ public KafkaDeployer(Source source, Properties properties) {
}
@Override
- public void validate(Validator.Issues issues) {
+ public void validate(Validator.Issues issues, Connection connection) {
String topicName = source.table();
// null default = option was not specified by user, skip validation for that option
Integer partitions = DeployerUtils.parseIntOption(source.options(), "partitions", null);
diff --git a/hoptimator-kafka/src/test/java/com/linkedin/hoptimator/kafka/KafkaDeployerTest.java b/hoptimator-kafka/src/test/java/com/linkedin/hoptimator/kafka/KafkaDeployerTest.java
index 8758bcbf..42848533 100644
--- a/hoptimator-kafka/src/test/java/com/linkedin/hoptimator/kafka/KafkaDeployerTest.java
+++ b/hoptimator-kafka/src/test/java/com/linkedin/hoptimator/kafka/KafkaDeployerTest.java
@@ -568,7 +568,7 @@ private TopicDescription mockTopicWithPartitions(int numPartitions) {
private Validator.Issues collectIssues(KafkaDeployer deployer) {
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
return issues;
}
diff --git a/hoptimator-kafka/src/test/resources/kafka-ddl-create-table.id b/hoptimator-kafka/src/test/resources/kafka-ddl-create-table.id
index 32efc51a..38304532 100644
--- a/hoptimator-kafka/src/test/resources/kafka-ddl-create-table.id
+++ b/hoptimator-kafka/src/test/resources/kafka-ddl-create-table.id
@@ -84,7 +84,41 @@ spec:
segment.bytes: 1073741824
!specify create-table-test
-# Clean up - drop table
+# ─────────────────────────────────────────────────────────────────────────────
+# Dependency guard: a Kafka topic referenced by an MV's pipeline — as either a
+# source or a sink — cannot be dropped until the dependent MV is removed.
+# Exercises the label-based check that protects Kafka topic drops that would
+# orphan an active downstream pipeline.
+#
+# A single MV exercises both edges. The MV is a partial view
+# (KAFKA."create-table-test$guard") whose implicit sink falls back to
+# create-table-test, and reads from existing-topic-2.
+# ─────────────────────────────────────────────────────────────────────────────
+
+# Pipeline reads from existing-topic-2 (source) and writes to create-table-test
+# (partial-view sink). Its Pipeline CRD gets a depends-on-* label per edge.
+create or replace materialized view KAFKA."create-table-test$guard" as select * from KAFKA."existing-topic-2";
+(0 rows modified)
+
+!update
+
+# Source-side guard: existing-topic-2 is a source of the MV's pipeline.
+drop table KAFKA."existing-topic-2";
+active pipeline(s) depend on it
+!error
+
+# Sink-side guard: create-table-test is the partial-view sink of the MV's pipeline.
+drop table KAFKA."create-table-test";
+active pipeline(s) depend on it
+!error
+
+# Drop the dependent MV first; its pipeline (and both labels) go away.
+drop materialized view KAFKA."create-table-test$guard";
+(0 rows modified)
+
+!update
+
+# Now sink drop should succeed
drop table KAFKA."create-table-test";
(0 rows modified)
diff --git a/hoptimator-logical/src/main/java/com/linkedin/hoptimator/logical/LogicalTableDeployer.java b/hoptimator-logical/src/main/java/com/linkedin/hoptimator/logical/LogicalTableDeployer.java
index 59258791..dbaf7ba3 100644
--- a/hoptimator-logical/src/main/java/com/linkedin/hoptimator/logical/LogicalTableDeployer.java
+++ b/hoptimator-logical/src/main/java/com/linkedin/hoptimator/logical/LogicalTableDeployer.java
@@ -1,7 +1,7 @@
package com.linkedin.hoptimator.logical;
+import java.sql.Connection;
import java.sql.SQLException;
-import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLNonTransientException;
import java.util.ArrayList;
import java.util.Collection;
@@ -27,6 +27,7 @@
import io.kubernetes.client.openapi.models.V1OwnerReference;
import com.linkedin.hoptimator.Deployer;
+import com.linkedin.hoptimator.PendingDelete;
import com.linkedin.hoptimator.Trigger;
import com.linkedin.hoptimator.UserJob;
import com.linkedin.hoptimator.Validated;
@@ -47,6 +48,7 @@
import com.linkedin.hoptimator.jdbc.HoptimatorConnection;
import com.linkedin.hoptimator.jdbc.HoptimatorDriver;
import com.linkedin.hoptimator.jdbc.HoptimatorDdlUtils;
+import com.linkedin.hoptimator.jdbc.ValidationService;
/**
@@ -86,7 +88,7 @@ public class LogicalTableDeployer implements Deployer, Validated {
this(source, tierProps, context, new K8sApi<>(context, K8sApiEndpoints.DATABASES));
}
- /** Package-private constructor for testing — accepts an injectable database API. */
+ /** Package-private constructor for testing — accepts an injectable Database K8s API. */
LogicalTableDeployer(Source source, Properties tierProps, K8sContext context,
K8sApi databasesApi) {
this.source = source;
@@ -113,7 +115,7 @@ K8sLogicalTableDeployer createLogicalTableDeployer(
* Called by {@link com.linkedin.hoptimator.jdbc.ValidationService} before deployment.
*/
@Override
- public void validate(Validator.Issues issues) {
+ public void validate(Validator.Issues issues, Connection connection) {
try {
// Pre-register the row type in tier schemas so deployers (e.g. VeniceDeployer) can
// call HoptimatorDriver.rowType() during their own validate() calls.
@@ -124,7 +126,7 @@ public void validate(Validator.Issues issues) {
Collection deployers = DeploymentService.deployers(tierSource, context.connection());
for (Deployer deployer : deployers) {
if (deployer instanceof Validated) {
- ((Validated) deployer).validate(issues);
+ ((Validated) deployer).validate(issues, connection);
}
}
}
@@ -206,15 +208,58 @@ private void deployAll(boolean update) throws SQLException {
}
}
+ /**
+ * Deletes a logical table.
+ *
+ * A logical DROP is structurally equivalent to running DROP TABLE on each tier plus
+ * deleting the LogicalTable CRD. We mirror that shape exactly: each tier goes through the
+ * same {@code validateOrThrow → DeploymentService.delete} pipeline a standalone DROP would.
+ * The {@link PendingDelete}'s {@code (selfOwnerKind, selfOwnerName)} pair identifies the
+ * LogicalTable CRD so the implicit inter-tier pipelines (owned by the CRD, cascade-deleted
+ * with it) are excluded from the dependent set — only external pipelines block.
+ *
+ *
+ * Per-tier dep check via the validator framework. Any active external pipeline blocks.
+ * Delete the {@code LogicalTable} CRD. K8s owner-ref cascade removes its implicit
+ * inter-tier Pipelines and their Flink/YAML children. Must succeed .
+ * Per-tier physical cleanup (Kafka topic, Venice store, ...). Best effort — a
+ * stranded tier resource is recoverable; aborting mid-DROP isn't.
+ * Per-tier schema cleanup (deregister the {@code TemporaryTable} in tier schemas).
+ *
+ */
@Override
public void delete() throws SQLException {
- // TODO: Implement safe logical table deletion.
- // Deletion is blocked until we can verify no active pipelines depend on this table.
- // See: LogicalTableDeployer.delete()
- throw new SQLFeatureNotSupportedException(
- "Logical table deletion is not yet supported. "
- + "Cannot safely delete physical tier resources without verifying no active pipelines "
- + "depend on this table.");
+ Map tierSources = buildTierSources();
+ HoptimatorConnection conn = context.connection();
+ String selfName = K8sUtils.canonicalizeName(source.path());
+
+ // 1. Per-tier pre-flight dep check.
+ for (Source tierSource : tierSources.values()) {
+ ValidationService.validateOrThrow(
+ new PendingDelete<>(tierSource, "LogicalTable", selfName), conn);
+ }
+
+ // 2. Delete the LogicalTable CRD (cascades owned pipelines/triggers).
+ createLogicalTableDeployer(selfName, source.database(), buildTierMap()).delete();
+
+ // 3. Per-tier physical cleanup. Best-effort: only deregister a tier's schema entry when its
+ // physical delete succeeded; failed tiers keep their entries so the user can retry.
+ for (Source tierSource : tierSources.values()) {
+ boolean tierSucceeded = true;
+ for (Deployer deployer : DeploymentService.deployers(tierSource, conn)) {
+ try {
+ deployer.delete();
+ } catch (Exception e) {
+ tierSucceeded = false;
+ log.warn("Tier cleanup failed for {} (continuing): {}",
+ tierSource.pathString(), e.getMessage(), e);
+ }
+ }
+ if (tierSucceeded) {
+ HoptimatorDdlUtils.removeTableFromSchema(conn,
+ tierSource.catalog(), tierSource.schema(), tierSource.table());
+ }
+ }
}
@Override
@@ -445,7 +490,8 @@ void deployPipelineBundle(String fromTier, String toTier, Map hoptimatorDriverMock;
+ @Mock
+ MockedStatic validationServiceMock;
+
@Mock
K8sLogicalTableDeployer mockCrdDeployer;
@@ -108,9 +117,9 @@ private static Properties twoTierProps(String nearlineDb, String offlineDb) {
private static K8sContext mockContext() {
K8sContext ctx = mock(K8sContext.class);
- when(ctx.namespace()).thenReturn("default");
- when(ctx.withOwner(any())).thenReturn(ctx);
- when(ctx.withLabel(anyString(), anyString())).thenReturn(ctx);
+ lenient().when(ctx.namespace()).thenReturn("default");
+ lenient().when(ctx.withOwner(any())).thenReturn(ctx);
+ lenient().when(ctx.withLabel(anyString(), anyString())).thenReturn(ctx);
return ctx;
}
@@ -228,17 +237,148 @@ void pipelineNameNearlineToOnline() {
LogicalTableDeployer.pipelineName("events", "nearline", "online"));
}
- // delete() / specify() tests
+ // delete() / DependencyGuarded / specify() tests
+
+ /** Builds a 2-tier deployer with mocked CRD deployer and a pre-populated fake Database API. */
+ private LogicalTableDeployer deployerWithApis(Properties props, List dbs) {
+ FakeK8sApi dbApi = new FakeK8sApi<>(new ArrayList<>(dbs));
+ return new LogicalTableDeployer(testSource(), props, mockContext(), dbApi) {
+ @Override
+ K8sLogicalTableDeployer createLogicalTableDeployer(
+ String crdName, String databaseLabel, Map tierMap) {
+ return mockCrdDeployer;
+ }
+ };
+ }
@Test
- void deleteThrowsSQLFeatureNotSupportedException() {
- Properties props = new Properties();
- props.setProperty("nearline", "kafka-db");
- props.setProperty("online", "venice-db");
+ void deleteThrowsWhenCrdDeleteFails() throws SQLException {
+ LogicalTableDeployer deployer = deployerWithApis(
+ twoTierProps("kafka-db", "venice-db"),
+ Arrays.asList(makeDb("kafka-db", "KAFKA"), makeDb("venice-db", "VENICE")));
+
+ doThrow(new SQLException("crd gone")).when(mockCrdDeployer).delete();
+
+ SQLException ex = assertThrows(SQLException.class, deployer::delete);
+ assertTrue(ex.getMessage().contains("crd gone"));
+ verify(mockCrdDeployer).delete();
+ // Tier deployers must not run when the CRD delete itself fails.
+ deploymentServiceMock.verify(
+ () -> DeploymentService.deployers(any(), any()), never());
+ }
+
+ @Test
+ void deleteSwallowsTierDeleteFailuresAndContinues() throws SQLException {
+ LogicalTableDeployer deployer = deployerWithApis(
+ twoTierProps("kafka-db", "venice-db"),
+ Arrays.asList(makeDb("kafka-db", "KAFKA"), makeDb("venice-db", "VENICE")));
+
+ // Two tier deployers: first throws, second succeeds. The overall delete must return cleanly.
+ Deployer failing = mock(Deployer.class);
+ Deployer succeeding = mock(Deployer.class);
+ doThrow(new SQLException("kafka delete failed")).when(failing).delete();
+
+ deploymentServiceMock.when(() -> DeploymentService.deployers(any(), any()))
+ .thenReturn(Collections.singletonList(failing), Collections.singletonList(succeeding));
+
+ // Must NOT throw despite the failing tier.
+ deployer.delete();
+
+ verify(mockCrdDeployer).delete();
+ verify(failing).delete();
+ verify(succeeding).delete();
+ }
+
+ /**
+ * Executes {@code body} with {@link HoptimatorDdlUtils} statics stubbed, then gives the
+ * supplied verifier a handle on the mock to assert side effects.
+ *
+ * Uses try-with-resources rather than the project-standard {@code @Mock MockedStatic}
+ * field because sibling tests in this class rely on the real static methods of
+ * {@code HoptimatorDdlUtils} (via {@code ensureTierRowTypesRegistered} and friends); a
+ * class-level mock would intercept them and break unrelated tests.
+ */
+ private void withMockedDdlUtils(Runnable body, Consumer> verifier) {
+ try (MockedStatic utilsMock = mockStatic(HoptimatorDdlUtils.class)) {
+ body.run();
+ verifier.accept(utilsMock);
+ }
+ }
+
+ @Test
+ void deleteRemovesTierEntriesFromConnectionSchema() throws SQLException {
+ LogicalTableDeployer deployer = deployerWithApis(
+ twoTierProps("kafka-db", "venice-db"),
+ Arrays.asList(makeDb("kafka-db", "KAFKA"), makeDb("venice-db", "VENICE")));
+
+ Deployer tierDeployer = mock(Deployer.class);
+ deploymentServiceMock.when(() -> DeploymentService.deployers(any(), any()))
+ .thenReturn(Collections.singletonList(tierDeployer));
+
+ withMockedDdlUtils(() -> {
+ try {
+ deployer.delete();
+ } catch (SQLException e) {
+ throw new RuntimeException(e);
+ }
+ }, utilsMock -> {
+ // Inverse of the registerTemporaryTableInSchema calls that ran at create time — one
+ // call per tier source (null catalog, non-null schema = tier "KAFKA" / "VENICE").
+ utilsMock.verify(() -> HoptimatorDdlUtils.removeTableFromSchema(
+ any(), any(), eq("KAFKA"), any()));
+ utilsMock.verify(() -> HoptimatorDdlUtils.removeTableFromSchema(
+ any(), any(), eq("VENICE"), any()));
+ });
+ }
+
+ @Test
+ void deleteKeepsSchemaEntryForTierWhoseDeleteFailed() throws SQLException {
+ // Two tiers: the first (kafka-db → KAFKA) fails to delete; the second succeeds.
+ // The failed tier's schema entry must NOT be removed; the succeeded tier's must be.
+ LogicalTableDeployer deployer = deployerWithApis(
+ twoTierProps("kafka-db", "venice-db"),
+ Arrays.asList(makeDb("kafka-db", "KAFKA"), makeDb("venice-db", "VENICE")));
+
+ Deployer failingTier = mock(Deployer.class);
+ Deployer succeedingTier = mock(Deployer.class);
+ doThrow(new SQLException("kafka delete failed")).when(failingTier).delete();
+
+ // DeploymentService.deployers is invoked once per tier — order follows tierSources.
+ deploymentServiceMock.when(() -> DeploymentService.deployers(any(), any()))
+ .thenReturn(Collections.singletonList(failingTier),
+ Collections.singletonList(succeedingTier));
+
+ withMockedDdlUtils(() -> {
+ try {
+ deployer.delete();
+ } catch (SQLException e) {
+ throw new RuntimeException(e);
+ }
+ }, utilsMock -> {
+ // KAFKA failed → its schema entry must NOT be removed.
+ utilsMock.verify(() -> HoptimatorDdlUtils.removeTableFromSchema(
+ any(), any(), eq("KAFKA"), any()), never());
+ // VENICE succeeded → its schema entry is removed.
+ utilsMock.verify(() -> HoptimatorDdlUtils.removeTableFromSchema(
+ any(), any(), eq("VENICE"), any()));
+ });
+ }
+
+ @Test
+ void deleteRunsCrdDeleteBeforeTierDeletes() throws SQLException {
+ LogicalTableDeployer deployer = deployerWithApis(
+ twoTierProps("kafka-db", "venice-db"),
+ Arrays.asList(makeDb("kafka-db", "KAFKA"), makeDb("venice-db", "VENICE")));
+
+ Deployer tierDeployer = mock(Deployer.class);
+ deploymentServiceMock.when(() -> DeploymentService.deployers(any(), any()))
+ .thenReturn(Collections.singletonList(tierDeployer));
+
+ deployer.delete();
- LogicalTableDeployer deployer = new LogicalTableDeployer(makeSource("mydb", "myTable"), props, null);
- SQLFeatureNotSupportedException e = assertThrows(SQLFeatureNotSupportedException.class, deployer::delete);
- assertTrue(e.getMessage().contains("Logical table deletion is not yet supported"));
+ InOrder inOrder = inOrder(mockCrdDeployer, tierDeployer);
+ inOrder.verify(mockCrdDeployer).delete();
+ inOrder.verify(tierDeployer, atLeastOnce()).delete();
}
// CRD model construction tests
@@ -516,7 +656,7 @@ void validateSucceedsWithValidTierConfiguration() throws Exception {
Validator.Issues issues = new Validator.Issues("test");
new LogicalTableDeployer(
testSource(), twoTierProps("nearline-db", "offline-db"), mockContext(), dbApi)
- .validate(issues);
+ .validate(issues, null);
assertTrue(issues.valid());
}
@@ -527,7 +667,7 @@ void validateReportsIssueWhenDatabaseNotFound() throws Exception {
new LogicalTableDeployer(
testSource(), twoTierProps("missing-db", "also-missing"),
mockContext(), new FakeK8sApi<>(new ArrayList<>()))
- .validate(issues);
+ .validate(issues, null);
assertFalse(issues.valid());
}
@@ -548,9 +688,9 @@ void validateCallsValidatedDeployersWhenTiersExist() throws Exception {
Validator.Issues issues = new Validator.Issues("test");
new LogicalTableDeployer(
makeSource("logical", "testevent"), oneTierProps, ctx, dbApi)
- .validate(issues);
+ .validate(issues, null);
- verify(mockValidatedDeployer).validate(issues);
+ verify(mockValidatedDeployer).validate(issues, null);
assertTrue(issues.valid());
}
@@ -594,7 +734,7 @@ void ensureTierRowTypesRegisteredWithConnectionRecordsRowTypeError() throws Exce
Validator.Issues issues = new Validator.Issues("test");
new LogicalTableDeployer(
makeSource("logical", "testevent"), oneTierProps, ctx, dbApi)
- .validate(issues);
+ .validate(issues, null);
assertFalse(issues.valid());
}
diff --git a/hoptimator-logical/src/test/resources/logical-ddl.id b/hoptimator-logical/src/test/resources/logical-ddl.id
index 7284ae72..a8ad290d 100644
--- a/hoptimator-logical/src/test/resources/logical-ddl.id
+++ b/hoptimator-logical/src/test/resources/logical-ddl.id
@@ -168,14 +168,7 @@ Failed to generate key schema for Venice store testevent
!describe "VENICE"."testevent"
# ─────────────────────────────────────────────────────────────────────────────
-# Test 7: DROP TABLE — disabled; should fail with helpful error message
-# ─────────────────────────────────────────────────────────────────────────────
-drop table "LOGICAL"."testevent";
-Logical table deletion is not yet supported
-!error
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Test 8: CREATE TABLE against non-existent schema is rejected
+# Test 7: CREATE TABLE against non-existent schema is rejected
# ─────────────────────────────────────────────────────────────────────────────
create table "LOGICAL-NONEXISTENT"."testevent" ("KEY" varchar, "id" bigint);
Schema for LOGICAL-NONEXISTENT.testevent not found.
@@ -220,8 +213,63 @@ spec:
!specify create-table-test
# ─────────────────────────────────────────────────────────────────────────────
-# Clean up (deletion not yet supported — verified in Test 6 above)
+# Test 9: Dependency guard — An underlying tier cannot be dropped if there is
+# an active logical table. Likewise, a MV created against any tier will block
+# the composite logical table drop
# ─────────────────────────────────────────────────────────────────────────────
-drop table "LOGICAL"."pageview";
-Logical table deletion is not yet supported
+
+# Drop is blocked — logical table inner pipeline depends on KAFKA/testevent
+drop table "KAFKA"."testevent";
+active pipeline(s) depend on it
!error
+
+create or replace materialized view VENICE."testevent$guard" as select "KEY" as "KEY", "VALUE" as "memberId" from KAFKA."existing-topic-1";
+(0 rows modified)
+
+!update
+
+# Drop is blocked — testevent$guard's pipeline depends on VENICE/testevent,
+# which is LOGICAL.testevent's online tier.
+drop table "LOGICAL"."testevent";
+active pipeline(s) depend on it
+!error
+
+# Drop the dependent MV first to release the label.
+drop materialized view VENICE."testevent$guard";
+(0 rows modified)
+
+!update
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Test 10: DROP TABLE — cascades to the LogicalTable CRD and its implicit
+# inter-tier pipeline; tier resources are best-effort cleaned up afterward.
+# ─────────────────────────────────────────────────────────────────────────────
+drop table "LOGICAL"."testevent";
+(0 rows modified)
+
+!update
+
+# The owner-ref cascade removes the implicit nearline→online pipeline.
+select name from "k8s".pipelines where name = 'logical-testevent-nearline-to-online';
++------+
+| NAME |
++------+
++------+
+(0 rows)
+
+!ok
+
+drop table "LOGICAL"."pageview";
+(0 rows modified)
+
+!update
+
+# Verify the pageview pipeline is gone too.
+select name from "k8s".pipelines where name = 'logical-pageview-nearline-to-online';
++------+
+| NAME |
++------+
++------+
+(0 rows)
+
+!ok
diff --git a/hoptimator-logical/src/test/resources/logical-offline-ddl.id b/hoptimator-logical/src/test/resources/logical-offline-ddl.id
index 82fcd538..9e154ba2 100644
--- a/hoptimator-logical/src/test/resources/logical-offline-ddl.id
+++ b/hoptimator-logical/src/test/resources/logical-offline-ddl.id
@@ -77,3 +77,8 @@ select paused from "k8s".table_triggers where name = 'logical-members-offline-tr
(1 row)
!ok
+
+drop table "LOGICAL_OFFLINE"."MEMBERS";
+(0 rows modified)
+
+!update
\ No newline at end of file
diff --git a/hoptimator-mcp-server/src/test/java/com/linkedin/hoptimator/mcp/server/HoptimatorMcpServerTest.java b/hoptimator-mcp-server/src/test/java/com/linkedin/hoptimator/mcp/server/HoptimatorMcpServerTest.java
index 4ea5cc9f..670ae7af 100644
--- a/hoptimator-mcp-server/src/test/java/com/linkedin/hoptimator/mcp/server/HoptimatorMcpServerTest.java
+++ b/hoptimator-mcp-server/src/test/java/com/linkedin/hoptimator/mcp/server/HoptimatorMcpServerTest.java
@@ -1,7 +1,7 @@
package com.linkedin.hoptimator.mcp.server;
-import com.google.gson.Gson;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import com.google.gson.Gson;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -34,9 +34,15 @@
import static org.mockito.Mockito.when;
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup do not need resource management")
@ExtendWith(MockitoExtension.class)
+@SuppressFBWarnings(
+ value = "OBL_UNSATISFIED_OBLIGATION",
+ justification = "Tests stub Connection.createStatement()/prepareStatement() and "
+ + "DatabaseMetaData getter methods on mocks. The Mockito DSL "
+ + "(when(mock.createStatement()).thenReturn(...) / .thenThrow(...)) requires invoking "
+ + "the AutoCloseable-returning method on the mock; SpotBugs flags every such "
+ + "invocation. The values are mocks and there is no alternative Mockito syntax that "
+ + "avoids the AutoCloseable call site.")
class HoptimatorMcpServerTest {
@Mock
diff --git a/hoptimator-mysql/src/main/java/com/linkedin/hoptimator/mysql/MySqlDeployer.java b/hoptimator-mysql/src/main/java/com/linkedin/hoptimator/mysql/MySqlDeployer.java
index c2d996d4..227600bb 100644
--- a/hoptimator-mysql/src/main/java/com/linkedin/hoptimator/mysql/MySqlDeployer.java
+++ b/hoptimator-mysql/src/main/java/com/linkedin/hoptimator/mysql/MySqlDeployer.java
@@ -31,8 +31,8 @@
/**
* Deployer for MySQL tables. Creates tables in the synchronous DDL hot path.
*
- * Implements {@link Validated} to pre-check table constraints
- * before any deployment side effects.
+ *
Implements {@link Validated} to pre-check table constraints before any deployment side
+ * effects.
*/
public class MySqlDeployer implements Deployer, Validated {
@@ -99,7 +99,7 @@ private String toMySqlType(RelDataTypeField field) {
}
@Override
- public void validate(Validator.Issues issues) {
+ public void validate(Validator.Issues issues, Connection connection) {
String tableName = source.table();
String database = source.schema();
@@ -170,11 +170,11 @@ public void validate(Validator.Issues issues) {
if (columnName.equals(keyField)) {
String newType = toMySqlType(field);
String existingType = existingCol.type;
-
+
// Normalize types for comparison (remove size for basic comparison)
String normalizedNew = newType.replaceAll("\\(.*?\\)", "");
String normalizedExisting = existingType.replaceAll("\\(.*?\\)", "");
-
+
if (!normalizedNew.equalsIgnoreCase(normalizedExisting)) {
issues.error("Cannot modify KEY field type for table " + tableName
+ ". KEY field '" + keyField + "' has existing type " + existingType
diff --git a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDeployerTest.java b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDeployerTest.java
index e2212aa0..08f352cf 100644
--- a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDeployerTest.java
+++ b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDeployerTest.java
@@ -1,10 +1,10 @@
package com.linkedin.hoptimator.mysql;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import com.linkedin.hoptimator.Source;
import com.linkedin.hoptimator.Validator;
import com.linkedin.hoptimator.jdbc.HoptimatorConnection;
import com.linkedin.hoptimator.jdbc.HoptimatorDriver;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFactory;
import org.apache.calcite.rel.type.RelDataTypeSystem;
@@ -49,8 +49,13 @@
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
+@SuppressFBWarnings(
+ value = "OBL_UNSATISFIED_OBLIGATION",
+ justification = "setUp() uses lenient().when(mockConnection.createStatement()).thenReturn(...) "
+ + "to stub the DDL-execution path. Mockito's API requires invoking the AutoCloseable-"
+ + "returning method on the mock, which SpotBugs flags as an unclosed Statement. The "
+ + "value is a mock; tests that exercise this path call "
+ + "verify(mockStatement).executeUpdate(...) to assert it was actually used.")
class MySqlDeployerTest {
private static final String DATABASE = "test_db";
@@ -85,8 +90,14 @@ void setUp() throws SQLException {
// lenient: not all tests open a real connection or use every stub
lenient().when(mockConnection.getMetaData()).thenReturn(mockMetaData);
lenient().when(mockConnection.createStatement()).thenReturn(mockStatement);
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
- .thenReturn(mockConnection);
+ // Wrap the recording-only invocation in try-with-resources so SpotBugs sees the
+ // AutoCloseable obligation discharged. During recording the intercepted Connection
+ // is null, so close() is skipped at runtime.
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ }).thenReturn(mockConnection);
// Mock HoptimatorDriver.rowType to return a simple schema with KEY_id and other fields
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
@@ -105,27 +116,19 @@ private MySqlDeployer createDeployer(Source source) {
private Validator.Issues collectIssues(MySqlDeployer deployer) {
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
return issues;
}
/**
- * Helper: stub connection metadata only (no statement). Used by validation-focused tests
- * that don't need statement execution.
+ * Helper: promote the lenient {@code mockConnection.getMetaData()} stub from {@link #setUp()}
+ * to a strict stub for tests that depend on it. Tests that issue DDL via {@code executeUpdate}
+ * should additionally {@code verify(mockStatement).executeUpdate(...)} to assert the DDL path
+ * was exercised — the lenient {@code createStatement()} stub in {@link #setUp()} covers the
+ * stubbing side.
*/
private void stubConnection() throws SQLException {
when(mockConnection.getMetaData()).thenReturn(mockMetaData);
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
- .thenReturn(mockConnection);
- }
-
- /**
- * Helper: stub connection metadata AND statement creation. Used by tests that issue DDL
- * via executeUpdate.
- */
- private void stubConnectionWithStatement() throws SQLException {
- stubConnection();
- when(mockConnection.createStatement()).thenReturn(mockStatement);
}
/**
@@ -403,7 +406,7 @@ void testValidateFailsWithNullDatabase() {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Database & table names are required"));
@@ -415,7 +418,7 @@ void testValidateFailsWithInvalidDatabaseName() {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Invalid database name"));
@@ -427,7 +430,7 @@ void testValidateFailsWithInvalidTableName() {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Invalid table name"));
@@ -439,7 +442,7 @@ void testValidateFailsWithEmptyDatabaseName() {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Invalid database name"));
@@ -466,7 +469,7 @@ void testValidateFailsNoKeyFields() throws SQLException {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("No KEY_ fields found"));
@@ -501,7 +504,7 @@ void testValidateFailsWhenPrimaryKeysChange() throws SQLException {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Cannot modify KEY fields"));
@@ -528,7 +531,7 @@ void testValidateFailsWithInvalidColumnName() throws SQLException {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Invalid column name"));
@@ -544,7 +547,7 @@ void testValidateFailsWhenRowTypeThrowsException() throws SQLException {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Failed to get schema for table"));
@@ -565,7 +568,7 @@ void testValidatePassesWithMaxLength64Identifier() throws SQLException {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertTrue(issues.valid(), "Expected 64-char identifier to be valid");
}
@@ -578,7 +581,7 @@ void testValidateFailsWithTooLongIdentifier() {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid(), "Expected 65-char identifier to be invalid");
assertTrue(issues.toString().contains("Invalid table name"),
@@ -598,7 +601,7 @@ void testValidatePassesWhenAllConditionsGood() throws SQLException {
MySqlDeployer deployer = new MySqlDeployer(source, PROPERTIES, mockHoptimatorConnection);
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertTrue(issues.valid(), "Expected no errors for valid new table, got: " + issues);
}
@@ -632,7 +635,7 @@ void testUpdateFailsWithNullDatabase() {
@Test
void testUpdateAltersExistingTableAddsColumn() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
// Row type with KEY_id, name, AND a new "email" column
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
@@ -675,7 +678,7 @@ void testUpdateAltersExistingTableAddsColumn() throws Exception {
@Test
void testUpdateAltersExistingTableNoChanges() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
stubDefaultRowType();
Source source = new Source("db", List.of("MYSQL", "test_db", "MyTable"), Collections.emptyMap());
@@ -709,7 +712,7 @@ void testUpdateAltersExistingTableNoChanges() throws Exception {
@Test
void testUpdateAltersExistingTableModifiesColumn() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
// Desired schema: KEY_id (INT), name (VARCHAR(500)) — name changes from VARCHAR(255) to VARCHAR(500)
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
@@ -752,7 +755,7 @@ void testUpdateAltersExistingTableModifiesColumn() throws Exception {
@Test
void testUpdateAltersExistingTableDropsColumn() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
stubDefaultRowType(); // KEY_id, name
Source source = new Source("db", List.of("MYSQL", "test_db", "DropTable"), Collections.emptyMap());
@@ -786,7 +789,7 @@ void testUpdateAltersExistingTableDropsColumn() throws Exception {
@Test
void testBuildDesiredColumnsInvalidColumnNameThrowsSqlException() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
RelDataTypeFactory.Builder builder = new RelDataTypeFactory.Builder(typeFactory);
@@ -882,7 +885,7 @@ static Stream typeMappingCases() {
@MethodSource("typeMappingCases")
void testToMySqlTypeExactSqlString(String label, SqlTypeName sqlType, int precision, int scale,
String expectedMySqlType) throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
RelDataTypeFactory.Builder builder = new RelDataTypeFactory.Builder(typeFactory);
@@ -926,7 +929,7 @@ void testToMySqlTypeExactSqlString(String label, SqlTypeName sqlType, int precis
@Test
void testToMySqlTypeVarcharWithPrecisionGivesVarcharN() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
RelDataTypeFactory.Builder builder = new RelDataTypeFactory.Builder(typeFactory);
@@ -957,7 +960,7 @@ void testToMySqlTypeVarcharWithPrecisionGivesVarcharN() throws Exception {
@Test
void testBuildCreateTableSqlNonNullableColumnContainsNotNull() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
RelDataTypeFactory.Builder builder = new RelDataTypeFactory.Builder(typeFactory);
@@ -990,7 +993,7 @@ void testBuildCreateTableSqlNonNullableColumnContainsNotNull() throws Exception
@Test
void testBuildCreateTableSqlNullableColumnDoesNotContainNotNull() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
RelDataTypeFactory.Builder builder = new RelDataTypeFactory.Builder(typeFactory);
@@ -1027,7 +1030,7 @@ void testBuildCreateTableSqlNullableColumnDoesNotContainNotNull() throws Excepti
@Test
void testBuildCreateTableSqlContainsPrimaryKey() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
RelDataTypeFactory.Builder builder = new RelDataTypeFactory.Builder(typeFactory);
@@ -1060,7 +1063,7 @@ void testBuildCreateTableSqlContainsPrimaryKey() throws Exception {
@Test
void testBuildCreateTableSqlVarcharWithLengthInDdl() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
RelDataTypeFactory.Builder builder = new RelDataTypeFactory.Builder(typeFactory);
@@ -1093,7 +1096,7 @@ void testBuildCreateTableSqlVarcharWithLengthInDdl() throws Exception {
@Test
void testAlterTableAddColumnSqlContainsAddColumn() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
// Desired: KEY_id, name, email (email is new)
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
@@ -1151,7 +1154,7 @@ void testAlterTableAddColumnSqlContainsAddColumn() throws Exception {
@Test
void testAlterTableModifyColumnSqlContainsModifyColumn() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
// Desired: KEY_id (INT), name VARCHAR(500) -- changed from 255
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
@@ -1206,7 +1209,7 @@ void testAlterTableModifyColumnSqlContainsModifyColumn() throws Exception {
@Test
void testAlterTableDropColumnSqlContainsDropColumn() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
stubDefaultRowType(); // KEY_id, name only
Source source = new Source("db", List.of("MYSQL", "test_db", "DropColTable"), Collections.emptyMap());
@@ -1252,7 +1255,7 @@ void testAlterTableDropColumnSqlContainsDropColumn() throws Exception {
@Test
void testEscapeIdentifierViaDeleteSqlContainsBacktickedName() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
Source source = new Source("db", List.of("MYSQL", "test_db", "myTable"), Collections.emptyMap());
@@ -1284,7 +1287,7 @@ void testEscapeIdentifierViaDeleteSqlContainsBacktickedName() throws Exception {
@Test
void testEnsureDatabaseExistsSqlContainsCreateDatabase() throws Exception {
- stubConnectionWithStatement();
+ stubConnection();
stubDefaultRowType();
Source source = new Source("db", List.of("MYSQL", "test_db", "SomeTable"), Collections.emptyMap());
@@ -1308,4 +1311,5 @@ void testEnsureDatabaseExistsSqlContainsCreateDatabase() throws Exception {
assertTrue(createDbSql.contains("`test_db`"),
"Expected backtick-escaped db name in CREATE DATABASE SQL, got: " + createDbSql);
}
+
}
diff --git a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDriverTest.java b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDriverTest.java
index 8f434bf4..ad9534c1 100644
--- a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDriverTest.java
+++ b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlDriverTest.java
@@ -1,6 +1,5 @@
package com.linkedin.hoptimator.mysql;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.jdbc.CalciteConnection;
import org.apache.calcite.schema.Schema;
import org.apache.calcite.schema.impl.AbstractSchema;
@@ -25,8 +24,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE", "DMI_EMPTY_DB_PASSWORD"},
- justification = "Mock objects do not hold real resources")
class MySqlDriverTest {
/** Returns a driver whose {@code createMySqlRootSchema()} yields a no-op schema. */
diff --git a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlRootSchemaTest.java b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlRootSchemaTest.java
index 56d4aab2..db152f4f 100644
--- a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlRootSchemaTest.java
+++ b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlRootSchemaTest.java
@@ -30,8 +30,12 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE", "DMI_EMPTY_DB_PASSWORD"},
- justification = "Mock objects do not hold real resources")
+@SuppressFBWarnings(
+ value = "DMI_EMPTY_DB_PASSWORD",
+ justification = "subSchemasUsesDefaultUserAndPassword deliberately exercises the empty "
+ + "user/password fallback that MySqlRootSchema applies when Properties does not "
+ + "contain credentials. The empty literal asserts that connect is invoked with empty "
+ + "credentials, not a real database secret.")
class MySqlRootSchemaTest {
@Mock
@@ -58,14 +62,22 @@ void setUp() {
private void stubConnection() throws SQLException {
when(mockConnection.getMetaData()).thenReturn(mockMetaData);
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenReturn(mockConnection);
}
@Test
void noConnectionOpenedOnConstruction() {
// Construction must not open any MySQL connection.
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenThrow(new RuntimeException("Should not connect at construction time"));
new MySqlRootSchema(properties); // must not throw
}
@@ -138,7 +150,11 @@ void subSchemasReturnsSameLookupInstance() {
@Test
void subSchemasGetThrowsOnConnectionError() {
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenThrow(new RuntimeException("Connection refused"));
MySqlRootSchema schema = new MySqlRootSchema(properties);
@@ -147,7 +163,11 @@ void subSchemasGetThrowsOnConnectionError() {
@Test
void subSchemasGetNamesThrowsOnConnectionError() {
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenThrow(new RuntimeException("Connection refused"));
MySqlRootSchema schema = new MySqlRootSchema(properties);
@@ -160,8 +180,11 @@ void subSchemasUsesDefaultUserAndPassword() throws SQLException {
minimal.setProperty("url", "jdbc:mysql://localhost:3306");
when(mockConnection.getMetaData()).thenReturn(mockMetaData);
- driverManagerStatic.when(() -> DriverManager.getConnection("jdbc:mysql://localhost:3306", "", ""))
- .thenReturn(mockConnection);
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection("jdbc:mysql://localhost:3306", "", "")) {
+ assert true; // recording-only
+ }
+ }).thenReturn(mockConnection);
when(mockMetaData.getCatalogs()).thenReturn(mockResultSet);
when(mockResultSet.next()).thenReturn(true, false);
when(mockResultSet.getString("TABLE_CAT")).thenReturn("mydb");
diff --git a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlTableTest.java b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlTableTest.java
index 720f5a0b..6b0bf948 100644
--- a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlTableTest.java
+++ b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/MySqlTableTest.java
@@ -1,6 +1,5 @@
package com.linkedin.hoptimator.mysql;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFactory;
import org.apache.calcite.rel.type.RelDataTypeSystem;
@@ -37,8 +36,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class MySqlTableTest {
private static final String DATABASE = "test_db";
@@ -71,8 +68,11 @@ void setUp() throws SQLException {
private void stubSuccessfulConnection() throws SQLException {
when(mockConnection.getMetaData()).thenReturn(mockMetaData);
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
- .thenReturn(mockConnection);
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ }).thenReturn(mockConnection);
}
@Test
diff --git a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/TableSchemaTest.java b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/TableSchemaTest.java
index 0eabfd3c..2589bcfb 100644
--- a/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/TableSchemaTest.java
+++ b/hoptimator-mysql/src/test/java/com/linkedin/hoptimator/mysql/TableSchemaTest.java
@@ -35,8 +35,12 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE", "DMI_EMPTY_DB_PASSWORD"},
- justification = "Mock objects created in stubbing setup don't need resource management")
+@SuppressFBWarnings(
+ value = "DMI_EMPTY_DB_PASSWORD",
+ justification = "tablesGetUsesDefaultUserAndPassword deliberately exercises the empty "
+ + "user/password fallback that TableSchema applies when Properties does not contain "
+ + "credentials. The empty literal asserts that connect is invoked with empty "
+ + "credentials, not a real database secret.")
class TableSchemaTest {
private static final String DATABASE = "test_db";
@@ -65,7 +69,11 @@ void setUp() {
private void stubConnection() throws SQLException {
when(mockConnection.getMetaData()).thenReturn(mockMetaData);
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenReturn(mockConnection);
}
@@ -99,7 +107,11 @@ void tablesGetReturnsNullWhenTableNotFound() throws SQLException {
@Test
void tablesGetThrowsOnConnectionError() {
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenThrow(new RuntimeException("Connection refused"));
TableSchema schema = new TableSchema(properties, DATABASE);
@@ -112,8 +124,11 @@ void tablesGetUsesDefaultUserAndPassword() throws SQLException {
minimal.setProperty("url", "jdbc:mysql://localhost:3306/test");
when(mockConnection.getMetaData()).thenReturn(mockMetaData);
- driverManagerStatic.when(() -> DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "", ""))
- .thenReturn(mockConnection);
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "", "")) {
+ assert true; // recording-only
+ }
+ }).thenReturn(mockConnection);
when(mockMetaData.getTables(eq(DATABASE), isNull(), eq("t"), any(String[].class)))
.thenReturn(mockResultSet);
when(mockResultSet.next()).thenReturn(true);
@@ -168,7 +183,11 @@ void tablesGetNamesReturnsEmptySetWhenNoTables() throws SQLException {
void getSchemaDescriptionIsNonEmptyInErrorMessage() {
// Make the DriverManager throw so that loadTable() throws, triggering the
// RuntimeException that includes getSchemaDescription() in its message.
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenThrow(new RuntimeException("simulated connection error"));
TableSchema schema = new TableSchema(properties, DATABASE);
@@ -190,7 +209,11 @@ void getSchemaDescriptionIsNonEmptyInErrorMessage() {
void getSchemaDescriptionIsNonEmptyInGetNamesErrorMessage() {
// Make the DriverManager throw during loadAllTables() to trigger the RuntimeException
// that includes getSchemaDescription() in its error message.
- driverManagerStatic.when(() -> DriverManager.getConnection(anyString(), anyString(), anyString()))
+ driverManagerStatic.when(() -> {
+ try (Connection c = DriverManager.getConnection(anyString(), anyString(), anyString())) {
+ assert true; // recording-only
+ }
+ })
.thenThrow(new RuntimeException("simulated connection error"));
TableSchema schema = new TableSchema(properties, DATABASE);
diff --git a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingConnectionTest.java b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingConnectionTest.java
index 91440127..b2f7e48d 100644
--- a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingConnectionTest.java
+++ b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingConnectionTest.java
@@ -1,6 +1,5 @@
package com.linkedin.hoptimator.util;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -28,8 +27,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "assertThrows lambdas call methods that always throw — no resource is ever returned")
class DelegatingConnectionTest {
@Mock
@@ -201,55 +198,91 @@ void testNativeSqlThrowsUnsupported() {
@Test
void testCreateStatementWithParamsThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.createStatement(1, 2));
+ () -> {
+ try (Statement s = connection.createStatement(1, 2)) {
+ assert true; // throws
+ }
+ });
}
@Test
void testCreateStatementWithThreeParamsThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.createStatement(1, 2, 3));
+ () -> {
+ try (Statement s = connection.createStatement(1, 2, 3)) {
+ assert true; // throws
+ }
+ });
}
@Test
void testPrepareStatementWithResultSetParamsThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.prepareStatement("SELECT 1", 1, 2));
+ () -> {
+ try (PreparedStatement s = connection.prepareStatement("SELECT 1", 1, 2)) {
+ assert true; // throws
+ }
+ });
}
@Test
void testPrepareStatementWithHoldabilityThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.prepareStatement("SELECT 1", 1, 2, 3));
+ () -> {
+ try (PreparedStatement s = connection.prepareStatement("SELECT 1", 1, 2, 3)) {
+ assert true; // throws
+ }
+ });
}
@Test
void testPrepareStatementWithAutoGeneratedKeysThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.prepareStatement("SELECT 1", 1));
+ () -> {
+ try (PreparedStatement s = connection.prepareStatement("SELECT 1", 1)) {
+ assert true; // throws
+ }
+ });
}
@Test
void testPrepareStatementWithColumnIndexesThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.prepareStatement("SELECT 1", new int[]{1}));
+ () -> {
+ try (PreparedStatement s = connection.prepareStatement("SELECT 1", new int[]{1})) {
+ assert true; // throws
+ }
+ });
}
@Test
void testPrepareStatementWithColumnNamesThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.prepareStatement("SELECT 1", new String[]{"col1"}));
+ () -> {
+ try (PreparedStatement s = connection.prepareStatement("SELECT 1", new String[]{"col1"})) {
+ assert true; // throws
+ }
+ });
}
@Test
void testPrepareCallWithParamsThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.prepareCall("CALL proc()", 1, 2));
+ () -> {
+ try (CallableStatement s = connection.prepareCall("CALL proc()", 1, 2)) {
+ assert true; // throws
+ }
+ });
}
@Test
void testPrepareCallWithHoldabilityThrowsUnsupported() {
assertThrows(SQLFeatureNotSupportedException.class,
- () -> connection.prepareCall("CALL proc()", 1, 2, 3));
+ () -> {
+ try (CallableStatement s = connection.prepareCall("CALL proc()", 1, 2, 3)) {
+ assert true; // throws
+ }
+ });
}
@Test
diff --git a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingDataSourceTest.java b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingDataSourceTest.java
index a52c05ec..a3ca8323 100644
--- a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingDataSourceTest.java
+++ b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingDataSourceTest.java
@@ -24,8 +24,12 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE", "DMI_CONSTANT_DB_PASSWORD"},
- justification = "Test uses invalid credentials intentionally; getConnection() throws before returning a resource")
+@SuppressFBWarnings(
+ value = "DMI_CONSTANT_DB_PASSWORD",
+ justification = "testGetConnectionWithCredentials* uses literal \"pass\" to verify Mockito "
+ + "stubbing of the credentialed getConnection overload. The value never reaches a "
+ + "real database — it is matched against a MockedStatic stub that throws or returns "
+ + "a mock Connection.")
class DelegatingDataSourceTest {
private DelegatingDataSource dataSource;
@@ -80,20 +84,36 @@ void testUnwrapReturnsNull() throws Exception {
void testSetUrlAndGetConnectionThrowsForBadUrl() {
dataSource.setUrl("jdbc:nonexistent://localhost/db");
SQLException ex = new SQLException("No suitable driver");
- mockedDriverManager.when(() -> DriverManager.getConnection("jdbc:nonexistent://localhost/db"))
- .thenThrow(ex);
-
- assertThrows(SQLException.class, () -> dataSource.getConnection());
+ mockedDriverManager.when(() -> {
+ try (Connection c = DriverManager.getConnection("jdbc:nonexistent://localhost/db")) {
+ assert true; // recording-only
+ }
+ }).thenThrow(ex);
+
+ assertThrows(SQLException.class,
+ () -> {
+ try (Connection c = dataSource.getConnection()) {
+ assert true; // throws
+ }
+ });
}
@Test
void testGetConnectionWithCredentialsThrowsForBadUrl() {
dataSource.setUrl("jdbc:nonexistent://localhost/db");
SQLException ex = new SQLException("No suitable driver");
- mockedDriverManager.when(() -> DriverManager.getConnection("jdbc:nonexistent://localhost/db", "user", "pass"))
- .thenThrow(ex);
-
- assertThrows(SQLException.class, () -> dataSource.getConnection("user", "pass"));
+ mockedDriverManager.when(() -> {
+ try (Connection c = DriverManager.getConnection("jdbc:nonexistent://localhost/db", "user", "pass")) {
+ assert true; // recording-only
+ }
+ }).thenThrow(ex);
+
+ assertThrows(SQLException.class,
+ () -> {
+ try (Connection c = dataSource.getConnection("user", "pass")) {
+ assert true; // throws
+ }
+ });
}
@Mock
@@ -106,8 +126,11 @@ void testGetConnectionWithCredentialsThrowsForBadUrl() {
void testGetConnectionReturnsWrappedConnection() throws SQLException {
dataSource.setUrl("jdbc:test://localhost/db");
- mockedDriverManager.when(() -> DriverManager.getConnection("jdbc:test://localhost/db"))
- .thenReturn(mockConnection);
+ mockedDriverManager.when(() -> {
+ try (Connection c = DriverManager.getConnection("jdbc:test://localhost/db")) {
+ assert true; // recording-only invocation; intercepted Connection closed via try-with-resources
+ }
+ }).thenReturn(mockConnection);
Connection conn = dataSource.getConnection();
@@ -119,8 +142,11 @@ void testGetConnectionReturnsWrappedConnection() throws SQLException {
void testGetConnectionWithCredentialsReturnsWrappedConnection() throws SQLException {
dataSource.setUrl("jdbc:test://localhost/db");
- mockedDriverManager.when(() -> DriverManager.getConnection("jdbc:test://localhost/db", "user", "pass"))
- .thenReturn(mockConnection);
+ mockedDriverManager.when(() -> {
+ try (Connection c = DriverManager.getConnection("jdbc:test://localhost/db", "user", "pass")) {
+ assert true; // recording-only
+ }
+ }).thenReturn(mockConnection);
Connection conn = dataSource.getConnection("user", "pass");
diff --git a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingStatementTest.java b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingStatementTest.java
index a8508206..c8075b96 100644
--- a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingStatementTest.java
+++ b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DelegatingStatementTest.java
@@ -26,8 +26,13 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
+@SuppressFBWarnings(
+ value = "OBL_UNSATISFIED_OBLIGATION",
+ justification = "Tests use when(mockConnection.createStatement()).thenReturn(...) and "
+ + "when(mockStatement.executeQuery(...)).thenReturn(...). Both are Mockito stubbing "
+ + "calls on methods whose AutoCloseable return types (Statement, ResultSet) trigger OBL "
+ + "at the bytecode call site. The values are mocks and the tests verify that "
+ + "DelegatingStatement.close() actually delegates close() to them.")
class DelegatingStatementTest {
@Mock
diff --git a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DeploymentServiceTest.java b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DeploymentServiceTest.java
index 1655a66d..554b71df 100644
--- a/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DeploymentServiceTest.java
+++ b/hoptimator-util/src/test/java/com/linkedin/hoptimator/util/DeploymentServiceTest.java
@@ -23,6 +23,7 @@
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
+
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
@@ -49,6 +50,18 @@
@ExtendWith(MockitoExtension.class)
class DeploymentServiceTest {
+ @Mock(answer = Answers.CALLS_REAL_METHODS)
+ private MockedStatic mockedDeploymentService;
+
+ @Mock
+ private Connection mockConnection;
+
+ @Mock
+ private Deployer mockDeployer1;
+
+ @Mock
+ private Deployer mockDeployer2;
+
/**
* "hint" keys and values are required to be non-{@code null}. An
* empty {@link Map} is considered invalid and should not be added
@@ -169,12 +182,6 @@ void parseHintsMixedEncodedAndNonEncoded() {
assertEquals("test", mixed.get("another"));
}
- @Mock
- private Deployer mockDeployer1;
-
- @Mock
- private Deployer mockDeployer2;
-
@Test
void testCreateCallsCreateOnAllDeployers() throws SQLException {
List deployers = Arrays.asList(mockDeployer1, mockDeployer2);
@@ -240,9 +247,6 @@ void testParseHintsWithPipelineOnlyEmptyValue() {
assertTrue(result.isEmpty());
}
- @Mock
- private Connection mockConnection;
-
@Test
void testProvidersReturnsSortedByPriority() {
Collection providers = DeploymentService.providers();
@@ -371,12 +375,6 @@ void testSpecifyDelegatesToDeployersAndCollectsSpecs() throws SQLException {
assertEquals("spec-b", specs.get(1));
}
- @Mock(answer = Answers.CALLS_REAL_METHODS)
- private MockedStatic mockedDeploymentService;
-
- @Mock
- private DeployerProvider mockProvider;
-
@Test
void testDeployersWithProvidersFiltersSubclasses() {
// The deployers() method uses providers() which relies on ServiceLoader
diff --git a/hoptimator-venice/src/main/java/com/linkedin/hoptimator/venice/VeniceDeployer.java b/hoptimator-venice/src/main/java/com/linkedin/hoptimator/venice/VeniceDeployer.java
index 28f34361..ee61a1b3 100644
--- a/hoptimator-venice/src/main/java/com/linkedin/hoptimator/venice/VeniceDeployer.java
+++ b/hoptimator-venice/src/main/java/com/linkedin/hoptimator/venice/VeniceDeployer.java
@@ -25,6 +25,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.sql.Connection;
import java.sql.SQLException;
import java.sql.SQLNonTransientException;
import java.util.Collections;
@@ -55,7 +56,7 @@ public VeniceDeployer(Source source, Properties properties, HoptimatorConnection
}
@Override
- public void validate(Validator.Issues issues) {
+ public void validate(Validator.Issues issues, Connection connection) {
String storeName = source.table();
// Validate Venice configuration
diff --git a/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/ClusterSchemaTest.java b/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/ClusterSchemaTest.java
index f033b5d1..c5b62272 100644
--- a/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/ClusterSchemaTest.java
+++ b/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/ClusterSchemaTest.java
@@ -6,7 +6,6 @@
import com.linkedin.venice.controllerapi.MultiStoreResponse;
import com.linkedin.venice.exceptions.ErrorType;
import com.linkedin.venice.security.SSLFactory;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.calcite.schema.Table;
import org.apache.calcite.schema.lookup.LikePattern;
import org.apache.calcite.schema.lookup.Lookup;
@@ -31,8 +30,6 @@
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class ClusterSchemaTest {
@Mock
@@ -197,7 +194,7 @@ void testCreateControllerClientWithNonLocalhostUrl() {
@Override
protected ControllerClient createControllerClient(String cluster, Optional sslFactory) {
// verify the non-localhost branch would be reached (url does not contain localhost)
- assertTrue(!properties.getProperty("router.url").contains("localhost"));
+ assertFalse(properties.getProperty("router.url").contains("localhost"));
return mockControllerClient;
}
};
diff --git a/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/VeniceDeployerTest.java b/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/VeniceDeployerTest.java
index b364cc29..9a16cea9 100644
--- a/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/VeniceDeployerTest.java
+++ b/hoptimator-venice/src/test/java/com/linkedin/hoptimator/venice/VeniceDeployerTest.java
@@ -10,7 +10,6 @@
import com.linkedin.venice.controllerapi.SchemaResponse;
import com.linkedin.venice.controllerapi.StoreResponse;
import com.linkedin.venice.meta.StoreInfo;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.avro.Schema;
import org.apache.calcite.util.Pair;
import org.junit.jupiter.api.BeforeEach;
@@ -40,8 +39,6 @@
* Tests for VeniceDeployer using mocks.
*/
@ExtendWith(MockitoExtension.class)
-@SuppressFBWarnings(value = {"OBL_UNSATISFIED_OBLIGATION", "ODR_OPEN_DATABASE_RESOURCE"},
- justification = "Mock objects created in stubbing setup don't need resource management")
class VeniceDeployerTest {
private static final String TEST_STORE = "test_store";
@@ -315,7 +312,7 @@ protected Pair getKeyPayloadSchema() throws SQLException {
};
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertTrue(issues.valid(), "Expected no validation errors for new store. Issues: " + issues);
}
@@ -348,7 +345,7 @@ protected Pair getKeyPayloadSchema() throws SQLException {
};
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertTrue(issues.valid(), "Expected no validation errors when key schema unchanged. Issues: " + issues);
}
@@ -381,7 +378,7 @@ protected Pair getKeyPayloadSchema() throws SQLException {
};
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid(), "Expected validation error for key schema change");
assertTrue(issues.toString().contains("Key schema evolution is not supported"),
@@ -400,7 +397,7 @@ protected Pair getKeyPayloadSchema() {
};
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Failed to generate key schema"));
@@ -418,7 +415,7 @@ protected Pair getKeyPayloadSchema() {
};
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Failed to generate value schema"));
@@ -436,10 +433,11 @@ protected Pair getKeyPayloadSchema() {
};
Validator.Issues issues = new Validator.Issues("test");
- deployer.validate(issues);
+ deployer.validate(issues, null);
assertFalse(issues.valid());
assertTrue(issues.toString().contains("Failed to generate key schema"));
assertTrue(issues.toString().contains("Failed to generate value schema"));
}
+
}