diff --git a/hoptimator-api/src/main/java/com/linkedin/hoptimator/DatabaseDeployable.java b/hoptimator-api/src/main/java/com/linkedin/hoptimator/DatabaseDeployable.java new file mode 100644 index 00000000..6976b9c0 --- /dev/null +++ b/hoptimator-api/src/main/java/com/linkedin/hoptimator/DatabaseDeployable.java @@ -0,0 +1,29 @@ +package com.linkedin.hoptimator; + +import java.util.Map; + + +/** Represents a CREATE DATABASE request. */ +public class DatabaseDeployable implements Deployable { + + private final String name; + private final Map options; + + public DatabaseDeployable(String name, Map options) { + this.name = name; + this.options = options; + } + + public String name() { + return name; + } + + public Map options() { + return options; + } + + @Override + public String toString() { + return "Database[" + name + "]"; + } +} diff --git a/hoptimator-jdbc/src/main/codegen/config.fmpp b/hoptimator-jdbc/src/main/codegen/config.fmpp index 8df8201d..c76f10ee 100644 --- a/hoptimator-jdbc/src/main/codegen/config.fmpp +++ b/hoptimator-jdbc/src/main/codegen/config.fmpp @@ -32,6 +32,7 @@ data: { "org.apache.calcite.sql.SqlTruncate" "org.apache.calcite.sql.ddl.SqlCreateTableLike" "org.apache.calcite.sql.ddl.SqlDdlNodes" + "com.linkedin.hoptimator.jdbc.ddl.SqlCreateDatabase" "com.linkedin.hoptimator.jdbc.ddl.SqlCreateFunction" "com.linkedin.hoptimator.jdbc.ddl.SqlCreateMaterializedView" "com.linkedin.hoptimator.jdbc.ddl.SqlCreateTable" @@ -91,6 +92,7 @@ data: { # Each must accept arguments "(SqlParserPos pos, boolean replace)". # Example: "SqlCreateForeignSchema". createStatementParserMethods: [ + "SqlCreateDatabase" "SqlCreateMaterializedView" "SqlCreateTrigger" "SqlCreateTable" diff --git a/hoptimator-jdbc/src/main/codegen/includes/parserImpls.ftl b/hoptimator-jdbc/src/main/codegen/includes/parserImpls.ftl index a3a92bd2..ea7f6d63 100644 --- a/hoptimator-jdbc/src/main/codegen/includes/parserImpls.ftl +++ b/hoptimator-jdbc/src/main/codegen/includes/parserImpls.ftl @@ -334,6 +334,20 @@ SqlCreate SqlCreateTrigger(Span s, boolean replace) : } } +SqlCreate SqlCreateDatabase(Span s, boolean replace) : +{ + final boolean ifNotExists; + final SqlIdentifier id; + SqlNodeList optionList = null; +} +{ + ifNotExists = IfNotExistsOpt() id = CompoundIdentifier() + [ optionList = Options() ] + { + return new SqlCreateDatabase(s.end(this), replace, ifNotExists, id, optionList); + } +} + SqlCreate SqlCreateFunction(Span s, boolean replace) : { final boolean ifNotExists; 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 224110f8..678de93b 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 @@ -25,6 +25,7 @@ import com.linkedin.hoptimator.UserJob; import com.linkedin.hoptimator.View; import com.linkedin.hoptimator.jdbc.ddl.HoptimatorDdlParserImpl; +import com.linkedin.hoptimator.jdbc.ddl.SqlCreateDatabase; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateMaterializedView; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateTable; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateTrigger; @@ -268,6 +269,19 @@ public void execute(SqlCreateTable create, CalcitePrepare.Context context) { logger.info("CREATE TABLE {} completed", create.name); } + /** Executes a {@code CREATE DATABASE} command. */ + public void execute(SqlCreateDatabase create, CalcitePrepare.Context context) { + HoptimatorDdlUtils.DdlMode mode = create.getReplace() + ? HoptimatorDdlUtils.DdlMode.UPDATE : HoptimatorDdlUtils.DdlMode.CREATE; + try { + HoptimatorDdlUtils.processCreateDatabase(connection, create, mode); + } catch (SQLException | RuntimeException e) { + logger.info("Failed to deploy database {}", create.name); + throw new DdlException(create, e.getMessage(), e); + } + logger.info("CREATE DATABASE {} completed", create.name); + } + /** Executes a {@code PAUSE TRIGGER} command. */ public void execute(SqlPauseTrigger pause, CalcitePrepare.Context context) { updateTriggerPausedState(pause, pause.name, true); 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 6ae4bb43..6ff1e551 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 @@ -21,10 +21,12 @@ import com.google.common.collect.ImmutableList; import com.linkedin.hoptimator.Database; +import com.linkedin.hoptimator.DatabaseDeployable; import com.linkedin.hoptimator.Deployer; import com.linkedin.hoptimator.MaterializedView; import com.linkedin.hoptimator.Pipeline; import com.linkedin.hoptimator.Source; +import com.linkedin.hoptimator.jdbc.ddl.SqlCreateDatabase; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateMaterializedView; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateTable; import com.linkedin.hoptimator.util.DeploymentService; @@ -621,6 +623,55 @@ public RexNode newColumnDefaultValue(RelOptTable table, int iColumn, } } + /** + * Shared implementation of the {@code CREATE DATABASE} pipeline for both real deployment + * and dry-run (SPECIFY) modes. + * + * @param conn the JDBC connection + * @param create the parsed DDL node + * @param mode whether to CREATE, UPDATE, or SPECIFY + * @return a SpecifyResult (specs are empty for CREATE/UPDATE, YAML for SPECIFY) + * @throws SQLException on validation or deployment errors + */ + static SpecifyResult processCreateDatabase(HoptimatorConnection conn, + SqlCreateDatabase create, DdlMode mode) throws SQLException { + HoptimatorConnection.HoptimatorConnectionDualLogger logger = conn.getLogger(HoptimatorDdlUtils.class); + + logger.info("Validating statement: {}", create); + ValidationService.validateOrThrow(create); + + if (create.name.names.size() > 1) { + throw new SQLException("Database names cannot be compound identifiers."); + } + String name = create.name.names.get(0); + + Map dbOptions = options(create.options); + DatabaseDeployable database = new DatabaseDeployable(name, dbOptions); + + Collection deployers = null; + try { + logger.info("Validating database {}", name); + ValidationService.validateOrThrow(database); + deployers = DeploymentService.deployers(database, conn); + ValidationService.validateOrThrow(deployers); + + List specs = mode.executeDeployers(deployers, conn); + if (mode.mutable()) { + logger.info("Deployed database {}", name); + } else { + DeploymentService.restore(deployers); + } + return new SpecifyResult(specs, null, Collections.singletonList(name)); + } catch (SQLException | RuntimeException e) { + logger.info("Failed to deploy database {}", name); + if (deployers != null) { + DeploymentService.restore(deployers); + logger.info("Restored deployable resources for database {}", name); + } + throw e; + } + } + /** * Returns the YAML specs that would be created for any supported SQL statement — * {@code CREATE TABLE}, {@code CREATE MATERIALIZED VIEW}, or {@code INSERT INTO}. @@ -643,6 +694,10 @@ public RexNode newColumnDefaultValue(RelOptTable table, int iColumn, public static SpecifyResult specifyFromSql(String sql, HoptimatorConnection conn) throws SQLException { SqlNode sqlNode = HoptimatorDriver.parseQuery(conn, sql); + if (sqlNode instanceof SqlCreateDatabase) { + return processCreateDatabase(conn, (SqlCreateDatabase) sqlNode, DdlMode.SPECIFY); + } + if (sqlNode instanceof SqlCreateTable) { return processCreateTable(conn.createPrepareContext(), conn, (SqlCreateTable) sqlNode, DdlMode.SPECIFY); } diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ddl/HoptimatorDdlParserImpl.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ddl/HoptimatorDdlParserImpl.java index 7588ab26..d6b9dc53 100644 --- a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ddl/HoptimatorDdlParserImpl.java +++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ddl/HoptimatorDdlParserImpl.java @@ -7,6 +7,7 @@ import org.apache.calcite.sql.SqlTruncate; import org.apache.calcite.sql.ddl.SqlCreateTableLike; import org.apache.calcite.sql.ddl.SqlDdlNodes; +import com.linkedin.hoptimator.jdbc.ddl.SqlCreateDatabase; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateFunction; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateMaterializedView; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateTable; @@ -6730,6 +6731,24 @@ final public SqlCreate SqlCreateTrigger(Span s, boolean replace) throws ParseExc throw new Error("Missing return statement in function"); } + final public SqlCreate SqlCreateDatabase(Span s, boolean replace) throws ParseException { + final boolean ifNotExists; + final SqlIdentifier id; + SqlNodeList optionList = null; + jj_consume_token(DATABASE); + ifNotExists = IfNotExistsOpt(); + id = CompoundIdentifier(); + switch ((jj_ntk==-1)?jj_ntk():jj_ntk) { + case WITH: + optionList = Options(); + break; + default: + ; + } + {if (true) return new SqlCreateDatabase(s.end(this), replace, ifNotExists, id, optionList);} + throw new Error("Missing return statement in function"); + } + final public SqlCreate SqlCreateFunction(Span s, boolean replace) throws ParseException { final boolean ifNotExists; final SqlIdentifier id; @@ -24461,6 +24480,9 @@ final public SqlCreate SqlCreate() throws ParseException { ; } switch ((jj_ntk==-1)?jj_ntk():jj_ntk) { + case DATABASE: + create = SqlCreateDatabase(s, replace); + break; case MATERIALIZED: create = SqlCreateMaterializedView(s, replace); break; diff --git a/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ddl/SqlCreateDatabase.java b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ddl/SqlCreateDatabase.java new file mode 100644 index 00000000..d41ff1a0 --- /dev/null +++ b/hoptimator-jdbc/src/main/java/com/linkedin/hoptimator/jdbc/ddl/SqlCreateDatabase.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.linkedin.hoptimator.jdbc.ddl; + +import org.apache.calcite.sql.SqlCreate; +import org.apache.calcite.sql.SqlIdentifier; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlNode; +import org.apache.calcite.sql.SqlNodeList; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.SqlSpecialOperator; +import org.apache.calcite.sql.SqlWriter; +import org.apache.calcite.sql.parser.SqlParserPos; +import org.apache.calcite.util.ImmutableNullableList; + +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** + * Parse tree for {@code CREATE DATABASE} statement. + */ +public class SqlCreateDatabase extends SqlCreate { + public final SqlIdentifier name; + public final @Nullable SqlNodeList options; + + private static final SqlOperator OPERATOR = + new SqlSpecialOperator("CREATE DATABASE", SqlKind.OTHER_DDL); + + /** Creates a SqlCreateDatabase. */ + protected SqlCreateDatabase(SqlParserPos pos, boolean replace, boolean ifNotExists, + SqlIdentifier name, @Nullable SqlNodeList options) { + super(OPERATOR, pos, replace, ifNotExists); + this.name = requireNonNull(name, "name"); + this.options = options; + } + + @SuppressWarnings("nullness") + @Override public List getOperandList() { + return ImmutableNullableList.of(name, options); + } + + @Override public void unparse(SqlWriter writer, int leftPrec, int rightPrec) { + writer.keyword("CREATE"); + writer.keyword("DATABASE"); + if (ifNotExists) { + writer.keyword("IF NOT EXISTS"); + } + name.unparse(writer, leftPrec, rightPrec); + if (options != null) { + writer.keyword("WITH"); + SqlWriter.Frame frame = writer.startList("(", ")"); + for (SqlNode c : options) { + writer.sep(","); + c.unparse(writer, 0, 0); + } + writer.endList(frame); + } + } +} 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 e70a1ee1..a6fee5d9 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 @@ -6,6 +6,7 @@ import com.linkedin.hoptimator.Sink; import com.linkedin.hoptimator.ThrowingFunction; import com.linkedin.hoptimator.util.DeploymentService; +import com.linkedin.hoptimator.jdbc.ddl.SqlCreateDatabase; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateMaterializedView; import com.linkedin.hoptimator.jdbc.ddl.SqlCreateTable; import com.linkedin.hoptimator.util.planner.PipelineRel; @@ -1497,6 +1498,102 @@ void processCreateMaterializedViewSpecifyModeCoversPipelinePlanningAndExecution( } } + // ── processCreateDatabase tests ───────────────────────────────────────────── + + @Test + void processCreateDatabaseSpecifyModeReturnsEmptySpecsWhenNoDeployersRegistered() + throws Exception { + HoptimatorDriver driver = new HoptimatorDriver(); + try (HoptimatorConnection conn = + (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) { + SqlCreateDatabase create = (SqlCreateDatabase) HoptimatorDriver.parseQuery(conn, + "CREATE DATABASE \"mydb\" WITH (url 'jdbc:mysql://localhost:3306/mydb')"); + + // No deployer providers are registered in the test env, so specs should be empty + HoptimatorDdlUtils.SpecifyResult result = + HoptimatorDdlUtils.processCreateDatabase(conn, create, HoptimatorDdlUtils.DdlMode.SPECIFY); + + assertNotNull(result); + assertNotNull(result.specs); + assertTrue(result.specs.isEmpty(), "Expected empty spec list when no deployers registered"); + } + } + + @Test + void processCreateDatabaseSpecifyModeViewPathContainsDatabaseName() throws Exception { + HoptimatorDriver driver = new HoptimatorDriver(); + try (HoptimatorConnection conn = + (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) { + SqlCreateDatabase create = (SqlCreateDatabase) HoptimatorDriver.parseQuery(conn, + "CREATE DATABASE \"mydb\" WITH (url 'jdbc:mysql://localhost:3306/mydb')"); + + HoptimatorDdlUtils.SpecifyResult result = + HoptimatorDdlUtils.processCreateDatabase(conn, create, HoptimatorDdlUtils.DdlMode.SPECIFY); + + assertNotNull(result.viewPath); + assertFalse(result.viewPath.isEmpty()); + assertEquals("mydb", result.viewPath.get(0)); + } + } + + @Test + void processCreateDatabaseThrowsForCompoundDatabaseName() throws Exception { + HoptimatorDriver driver = new HoptimatorDriver(); + try (HoptimatorConnection conn = + (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) { + // Construct a SqlCreateDatabase with a compound identifier directly since the parser + // only accepts simple names; using the protected constructor via reflection is not allowed + // so we exercise validation via the parse path with a manually constructed node. + SqlIdentifier compoundName = + new SqlIdentifier(Arrays.asList("catalog", "mydb"), SqlParserPos.ZERO); + SqlCreateDatabase create = new SqlCreateDatabase( + SqlParserPos.ZERO, false, false, compoundName, null) { }; + + SQLException ex = assertThrows(SQLException.class, + () -> HoptimatorDdlUtils.processCreateDatabase( + conn, create, HoptimatorDdlUtils.DdlMode.SPECIFY)); + assertTrue(ex.getMessage().contains("compound"), + "Expected 'compound' error but got: " + ex.getMessage()); + } + } + + @Test + void processCreateDatabaseSpecifyModeWithNoOptionsReturnsEmpty() throws Exception { + HoptimatorDriver driver = new HoptimatorDriver(); + try (HoptimatorConnection conn = + (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) { + // A database with no options — deployers will fail validation (missing url) or there + // are no deployers, so either throws SQLException or returns empty specs. + SqlCreateDatabase create = (SqlCreateDatabase) HoptimatorDriver.parseQuery(conn, + "CREATE DATABASE \"mydb\""); + + // With no deployers registered, no validation on the deployable happens, so it should + // return an empty spec list. + HoptimatorDdlUtils.SpecifyResult result = + HoptimatorDdlUtils.processCreateDatabase(conn, create, HoptimatorDdlUtils.DdlMode.SPECIFY); + + assertNotNull(result); + assertTrue(result.specs.isEmpty()); + } + } + + @Test + void specifyFromSqlForCreateDatabaseReturnsEmptySpecsWhenNoDeployersRegistered() + throws Exception { + HoptimatorDriver driver = new HoptimatorDriver(); + try (HoptimatorConnection conn = + (HoptimatorConnection) driver.connect("jdbc:hoptimator://catalogs=util", new Properties())) { + HoptimatorDdlUtils.SpecifyResult result = HoptimatorDdlUtils.specifyFromSql( + "CREATE DATABASE \"testdb\" WITH (url 'jdbc:mysql://localhost/testdb')", conn); + + assertNotNull(result); + assertNotNull(result.specs); + assertTrue(result.specs.isEmpty(), "specifyFromSql should route CREATE DATABASE to processCreateDatabase"); + assertNotNull(result.viewPath); + assertEquals("testdb", result.viewPath.get(0)); + } + } + static class TestDatabaseSchema extends AbstractSchema implements com.linkedin.hoptimator.Database { private final String name; diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sDatabaseDeployer.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sDatabaseDeployer.java new file mode 100644 index 00000000..981bfc77 --- /dev/null +++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sDatabaseDeployer.java @@ -0,0 +1,54 @@ +package com.linkedin.hoptimator.k8s; + +import com.linkedin.hoptimator.DatabaseDeployable; +import com.linkedin.hoptimator.k8s.models.V1alpha1Database; +import com.linkedin.hoptimator.k8s.models.V1alpha1DatabaseList; +import com.linkedin.hoptimator.k8s.models.V1alpha1DatabaseSpec; +import io.kubernetes.client.openapi.models.V1ObjectMeta; + +import java.sql.SQLException; +import java.util.Map; +import java.util.TreeMap; + + +/** Deploys a Database object. */ +class K8sDatabaseDeployer extends K8sDeployer { + + private final DatabaseDeployable database; + + K8sDatabaseDeployer(DatabaseDeployable database, K8sContext context) { + super(context, K8sApiEndpoints.DATABASES); + this.database = database; + } + + @Override + protected V1alpha1Database toK8sObject() throws SQLException { + String name = K8sUtils.canonicalizeName(database.name()); + // SQL parser uppercases unquoted identifiers, so use case-insensitive lookup + Map options = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + options.putAll(database.options()); + String url = options.get("url"); + if (url == null) { + throw new SQLException("Database " + database.name() + " requires a 'url' option."); + } + V1alpha1DatabaseSpec spec = new V1alpha1DatabaseSpec() + .url(url); + if (options.containsKey("driver")) { + spec.driver(options.get("driver")); + } + if (options.containsKey("schema")) { + spec.schema(options.get("schema")); + } + if (options.containsKey("catalog")) { + spec.catalog(options.get("catalog")); + } + if (options.containsKey("dialect")) { + spec.dialect(V1alpha1DatabaseSpec.DialectEnum.fromValue(options.get("dialect"))); + } + return new V1alpha1Database() + .kind(K8sApiEndpoints.DATABASES.kind()) + .apiVersion(K8sApiEndpoints.DATABASES.apiVersion()) + .metadata(new V1ObjectMeta().name(name)) + .spec(spec); + } +} diff --git a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sDeployerProvider.java b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sDeployerProvider.java index a8b89ace..2464ff73 100644 --- a/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sDeployerProvider.java +++ b/hoptimator-k8s/src/main/java/com/linkedin/hoptimator/k8s/K8sDeployerProvider.java @@ -1,5 +1,6 @@ package com.linkedin.hoptimator.k8s; +import com.linkedin.hoptimator.DatabaseDeployable; import com.linkedin.hoptimator.Deployable; import com.linkedin.hoptimator.Deployer; import com.linkedin.hoptimator.DeployerProvider; @@ -31,6 +32,8 @@ public Collection deployers(T obj, Connection c list.add(new K8sSourceDeployer((Source) obj, context)); } else if (obj instanceof Trigger) { list.add(new K8sTriggerDeployer((Trigger) obj, context)); + } else if (obj instanceof DatabaseDeployable) { + list.add(new K8sDatabaseDeployer((DatabaseDeployable) obj, context)); } return list; diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDatabaseDeployerTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDatabaseDeployerTest.java new file mode 100644 index 00000000..77f5bf0f --- /dev/null +++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDatabaseDeployerTest.java @@ -0,0 +1,224 @@ +package com.linkedin.hoptimator.k8s; + +import com.linkedin.hoptimator.DatabaseDeployable; +import com.linkedin.hoptimator.k8s.models.V1alpha1Database; +import com.linkedin.hoptimator.k8s.models.V1alpha1DatabaseList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +class K8sDatabaseDeployerTest { + + private List objects; + private FakeK8sApi fakeApi; + private FakeK8sYamlApi fakeYamlApi; + private K8sSnapshot snapshot; + + @BeforeEach + void setUp() { + objects = new ArrayList<>(); + fakeApi = new FakeK8sApi<>(objects); + Map yamls = new HashMap<>(); + fakeYamlApi = new FakeK8sYamlApi(yamls); + snapshot = new K8sSnapshot(null) { + @Override + K8sYamlApi createYamlApi(K8sContext context) { + return fakeYamlApi; + } + }; + } + + private K8sDatabaseDeployer makeDeployer(DatabaseDeployable database) { + FakeK8sApi capturedApi = fakeApi; + K8sSnapshot capturedSnapshot = snapshot; + return new K8sDatabaseDeployer(database, null) { + @Override + K8sApi createApi(K8sContext context, + K8sApiEndpoint endpoint) { + return capturedApi; + } + + @Override + K8sSnapshot createSnapshot(K8sContext context) { + return capturedSnapshot; + } + }; + } + + @Test + void specifyWithUrlOnlyReturnsYaml() throws SQLException { + Map options = new HashMap<>(); + options.put("url", "jdbc:mysql://localhost:3306/mydb"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + List specs = deployer.specify(); + + assertEquals(1, specs.size()); + assertTrue(specs.get(0).contains("mydb")); + assertTrue(specs.get(0).contains("jdbc:mysql://localhost:3306/mydb")); + } + + @Test + void specifyWithAllOptionsPopulatesSpec() throws SQLException { + Map options = new HashMap<>(); + options.put("url", "jdbc:mysql://localhost:3306/mydb"); + options.put("driver", "com.mysql.cj.jdbc.Driver"); + options.put("schema", "myschema"); + options.put("catalog", "mycatalog"); + options.put("dialect", "MySQL"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + List specs = deployer.specify(); + + assertEquals(1, specs.size()); + String yaml = specs.get(0); + assertTrue(yaml.contains("mydb")); + assertTrue(yaml.contains("jdbc:mysql://localhost:3306/mydb")); + assertTrue(yaml.contains("com.mysql.cj.jdbc.Driver")); + assertTrue(yaml.contains("myschema")); + assertTrue(yaml.contains("mycatalog")); + } + + @Test + void specifyWithUppercasedOptionKeysUsesCaseInsensitiveLookup() throws SQLException { + // SQL parser uppercases unquoted identifiers, so WITH (url '...') produces key "URL" + Map options = new HashMap<>(); + options.put("URL", "jdbc:mysql://localhost:3306/mydb"); + options.put("DRIVER", "com.mysql.cj.jdbc.Driver"); + options.put("SCHEMA", "myschema"); + options.put("DIALECT", "MySQL"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + List specs = deployer.specify(); + + assertEquals(1, specs.size()); + String yaml = specs.get(0); + assertTrue(yaml.contains("jdbc:mysql://localhost:3306/mydb")); + assertTrue(yaml.contains("com.mysql.cj.jdbc.Driver")); + assertTrue(yaml.contains("myschema")); + } + + @Test + void specifyThrowsWhenUrlMissing() { + Map options = new HashMap<>(); + options.put("driver", "com.mysql.cj.jdbc.Driver"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + + assertThrows(SQLException.class, deployer::specify); + } + + @Test + void specifyWithEmptyOptionsThrowsForMissingUrl() { + DatabaseDeployable database = new DatabaseDeployable("mydb", Collections.emptyMap()); + + K8sDatabaseDeployer deployer = makeDeployer(database); + + assertThrows(SQLException.class, deployer::specify); + } + + @Test + void createAddsObjectToApi() throws SQLException { + Map options = new HashMap<>(); + options.put("url", "jdbc:mysql://localhost:3306/mydb"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + deployer.create(); + + assertEquals(1, objects.size()); + assertEquals("mydb", objects.get(0).getMetadata().getName()); + } + + @Test + void specifyYamlContainsCorrectApiVersionAndKind() throws SQLException { + Map options = new HashMap<>(); + options.put("url", "jdbc:mysql://localhost:3306/mydb"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + List specs = deployer.specify(); + + assertEquals(1, specs.size()); + String yaml = specs.get(0); + assertTrue(yaml.contains("Database"), "spec must contain kind 'Database'"); + assertTrue(yaml.contains("hoptimator.linkedin.com"), "spec must contain the Hoptimator API group"); + } + + @Test + void specifyWithNoDriverDoesNotIncludeDriverField() throws SQLException { + Map options = new HashMap<>(); + options.put("url", "jdbc:mysql://localhost:3306/mydb"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + List specs = deployer.specify(); + + assertEquals(1, specs.size()); + // driver field must be absent when not supplied + String yaml = specs.get(0); + // The spec object's driver field should be null, so YAML serialization omits it + V1alpha1Database created = objects.isEmpty() ? null : objects.get(0); + assertNull(created); + // Verify via the spec content: url is present but driver is not explicitly set + assertTrue(yaml.contains("url: jdbc:mysql://localhost:3306/mydb")); + } + + @Test + void specifyNameIsCanonicalizedToLowerKebabCase() throws SQLException { + Map options = new HashMap<>(); + options.put("url", "jdbc:mysql://localhost:3306/MyDb"); + DatabaseDeployable database = new DatabaseDeployable("MyDb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + List specs = deployer.specify(); + + assertEquals(1, specs.size()); + // K8sUtils.canonicalizeName converts to lower-kebab-case + assertTrue(specs.get(0).contains("mydb"), "name must be canonicalized to lower-kebab-case"); + } + + @Test + void specifyWithAnsiDialectSetsDialectEnum() throws SQLException { + Map options = new HashMap<>(); + options.put("url", "jdbc:calcite://"); + options.put("dialect", "ANSI"); + DatabaseDeployable database = new DatabaseDeployable("testdb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + List specs = deployer.specify(); + + assertEquals(1, specs.size()); + // ANSI dialect should be reflected in the spec + assertNotNull(specs.get(0)); + } + + @Test + void specifyWithInvalidDialectThrowsIllegalArgumentException() { + Map options = new HashMap<>(); + options.put("url", "jdbc:mysql://localhost/mydb"); + options.put("dialect", "InvalidDialect"); + DatabaseDeployable database = new DatabaseDeployable("mydb", options); + + K8sDatabaseDeployer deployer = makeDeployer(database); + + assertThrows(IllegalArgumentException.class, deployer::specify); + } +} diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDeployerProviderTest.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDeployerProviderTest.java index cc6945ac..4f893758 100644 --- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDeployerProviderTest.java +++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/K8sDeployerProviderTest.java @@ -1,5 +1,6 @@ package com.linkedin.hoptimator.k8s; +import com.linkedin.hoptimator.DatabaseDeployable; import com.linkedin.hoptimator.Deployable; import com.linkedin.hoptimator.Deployer; import com.linkedin.hoptimator.Job; @@ -101,6 +102,18 @@ void deployersForTriggerReturnsTriggerDeployer() { assertInstanceOf(K8sTriggerDeployer.class, deployers.iterator().next()); } + @Test + void deployersForDatabaseDeployableReturnsDatabaseDeployer() { + contextStatic.when(() -> K8sContext.create(any(Connection.class))).thenReturn(context); + K8sDeployerProvider provider = new K8sDeployerProvider(); + DatabaseDeployable database = mock(DatabaseDeployable.class); + + Collection deployers = provider.deployers(database, connection); + + assertEquals(1, deployers.size()); + assertInstanceOf(K8sDatabaseDeployer.class, deployers.iterator().next()); + } + @Test void deployersForUnknownTypeReturnsEmpty() { contextStatic.when(() -> K8sContext.create(any(Connection.class))).thenReturn(context); diff --git a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/TestSqlScripts.java b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/TestSqlScripts.java index 3a34889c..4114448e 100644 --- a/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/TestSqlScripts.java +++ b/hoptimator-k8s/src/test/java/com/linkedin/hoptimator/k8s/TestSqlScripts.java @@ -42,4 +42,9 @@ public void k8sTriggerPauseResume() throws Exception { public void k8sTriggerOptions() throws Exception { run("k8s-trigger-options.id"); } + + @Test + public void k8sDdlCreateDatabase() throws Exception { + run("k8s-ddl-create-database.id"); + } } diff --git a/hoptimator-k8s/src/test/resources/k8s-ddl-create-database.id b/hoptimator-k8s/src/test/resources/k8s-ddl-create-database.id new file mode 100644 index 00000000..bd90a55e --- /dev/null +++ b/hoptimator-k8s/src/test/resources/k8s-ddl-create-database.id @@ -0,0 +1,14 @@ +!set outputformat mysql +!use k8s + +create database "mydb" WITH (url 'jdbc:mysql://localhost:3306/mydb', driver 'com.mysql.cj.jdbc.Driver', schema 'myschema', dialect 'MySQL'); +apiVersion: hoptimator.linkedin.com/v1alpha1 +kind: Database +metadata: + name: mydb +spec: + dialect: MYSQL + driver: com.mysql.cj.jdbc.Driver + schema: myschema + url: jdbc:mysql://localhost:3306/mydb +!specify mydb