diff --git a/flyway-database-ydb/pom.xml b/flyway-database-ydb/pom.xml new file mode 100644 index 0000000..88a181b --- /dev/null +++ b/flyway-database-ydb/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + org.flywaydb + flyway-community-db-support + 10.11.0 + + + flyway-database-ydb + Support Flyway YDB Dialect + + + + ${project.groupId} + flyway-core + + + + + + + src/main/resources + true + + + + + maven-resources-plugin + + + maven-jar-plugin + + + + \ No newline at end of file diff --git a/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbConnection.java b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbConnection.java new file mode 100644 index 0000000..9e897ba --- /dev/null +++ b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbConnection.java @@ -0,0 +1,27 @@ +package org.flywaydb.community.database.ydb; + +import org.flywaydb.core.internal.database.base.Connection; + +/** + * @author Kirill Kurdyukov + */ +public class YdbConnection extends Connection { + + private static final String YDB_SCHEMA_NAME = ""; + + protected YdbConnection(YdbDatabase database, java.sql.Connection connection) { + super(database, connection); + + this.jdbcTemplate = new YdbJdbcTemplate(connection, database.getDatabaseType()); + } + + @Override + protected String getCurrentSchemaNameOrSearchPath() { + return YDB_SCHEMA_NAME; // schema isn't supported + } + + @Override + public YdbSchema getSchema(String name) { + return new YdbSchema(jdbcTemplate, database, YDB_SCHEMA_NAME); + } +} diff --git a/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbDatabase.java b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbDatabase.java new file mode 100644 index 0000000..2040afd --- /dev/null +++ b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbDatabase.java @@ -0,0 +1,118 @@ +package org.flywaydb.community.database.ydb; + +import java.sql.Connection; +import org.flywaydb.core.api.configuration.Configuration; +import org.flywaydb.core.internal.database.base.Database; +import org.flywaydb.core.internal.database.base.Table; +import org.flywaydb.core.internal.jdbc.JdbcConnectionFactory; +import org.flywaydb.core.internal.jdbc.StatementInterceptor; + +/** + * @author Kirill Kurdyukov + */ +public class YdbDatabase extends Database { + + public YdbDatabase( + Configuration configuration, + JdbcConnectionFactory jdbcConnectionFactory, + StatementInterceptor statementInterceptor + ) { + super(configuration, jdbcConnectionFactory, statementInterceptor); + } + + @Override + protected YdbConnection doGetConnection(Connection connection) { + return new YdbConnection(this, connection); + } + + @Override + public void ensureSupported(Configuration configuration) { + } + + @Override + public boolean supportsDdlTransactions() { + return false; + } + + @Override + public String getBooleanTrue() { + return "TRUE"; + } + + @Override + public String getBooleanFalse() { + return "FALSE"; + } + + @Override + public boolean catalogIsSchema() { + return false; + } + + @Override + public String getRawCreateScript(Table table, boolean baseline) { + return "CREATE TABLE " + doQuote(table.getName()) + " (\n" + + " installed_rank INT32 NOT NULL,\n" + + " version TEXT,\n" + + " description TEXT,\n" + + " type TEXT,\n" + + " script TEXT,\n" + + " checksum INT32,\n" + + " installed_by TEXT,\n" + + " installed_on DATETIME,\n" + + " execution_time INT32,\n" + + " success BOOL,\n" + + " PRIMARY KEY (installed_rank)" + + ");\n" + + (baseline ? getBaselineStatement(table) : ""); + } + + @Override + public String getSelectStatement(Table table) { + return "SELECT " + quote("installed_rank") + + "," + quote("version") + + "," + quote("description") + + "," + quote("type") + + "," + quote("script") + + "," + quote("checksum") + + "," + quote("installed_on") + + "," + quote("installed_by") + + "," + quote("execution_time") + + "," + quote("success") + + " FROM " + quote(table.getName()) + + " WHERE " + quote("installed_rank") + " > ?" + + " ORDER BY " + quote("installed_rank"); + } + + @Override + public String getInsertStatement(Table table) { + return "INSERT INTO " + quote(table.getName()) + + " (" + quote("installed_rank") + + ", " + quote("version") + + ", " + quote("description") + + ", " + quote("type") + + ", " + quote("script") + + ", " + quote("checksum") + + ", " + quote("installed_by") + + ", " + quote("execution_time") + + ", " + quote("success") + + ", " + quote("installed_on") + + ")" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CurrentUtcDatetime())"; + } + + @Override + protected String getOpenQuote() { + return "`"; + } + + @Override + protected String getCloseQuote() { + return "`"; + } + + @Override + protected String getEscapedQuote() { + return "\\"; + } +} diff --git a/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbDatabaseType.java b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbDatabaseType.java new file mode 100644 index 0000000..b4abefb --- /dev/null +++ b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbDatabaseType.java @@ -0,0 +1,68 @@ +package org.flywaydb.community.database.ydb; + +import java.sql.Connection; +import java.sql.Types; +import org.flywaydb.core.api.ResourceProvider; +import org.flywaydb.core.api.configuration.Configuration; +import org.flywaydb.core.internal.database.base.BaseDatabaseType; +import org.flywaydb.core.internal.database.base.Database; +import org.flywaydb.core.internal.jdbc.JdbcConnectionFactory; +import org.flywaydb.core.internal.jdbc.StatementInterceptor; +import org.flywaydb.core.internal.parser.Parser; +import org.flywaydb.core.internal.parser.ParsingContext; + +/** + * @author Kirill Kurdyukov + */ +public class YdbDatabaseType extends BaseDatabaseType { + + private static final String YDB_NAME = "YDB"; + private final static String DRIVER_NAME = "tech.ydb.jdbc.YdbDriver"; + + @Override + public String getName() { + return YDB_NAME; + } + + @Override + public int getNullType() { + return Types.NULL; + } + + @Override + public boolean handlesJDBCUrl(String url) { + return url.startsWith("jdbc:ydb:"); + } + + @Override + public String getDriverClass(String url, ClassLoader classLoader) { + return DRIVER_NAME; + } + + @Override + public boolean handlesDatabaseProductNameAndVersion( + String databaseProductName, + String databaseProductVersion, + Connection connection + ) { + return databaseProductName.startsWith(YDB_NAME); + } + + @Override + public Database createDatabase( + Configuration configuration, + JdbcConnectionFactory jdbcConnectionFactory, + StatementInterceptor statementInterceptor + ) { + return new YdbDatabase(configuration, jdbcConnectionFactory, statementInterceptor); + } + + @Override + public Parser createParser( + Configuration configuration, + ResourceProvider resourceProvider, + ParsingContext parsingContext + ) { + return new YdbParser(configuration, parsingContext); + } +} diff --git a/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbJdbcTemplate.java b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbJdbcTemplate.java new file mode 100644 index 0000000..73070b5 --- /dev/null +++ b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbJdbcTemplate.java @@ -0,0 +1,39 @@ +package org.flywaydb.community.database.ydb; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import org.flywaydb.core.internal.database.DatabaseType; +import static org.flywaydb.core.internal.jdbc.JdbcNullTypes.BooleanNull; +import static org.flywaydb.core.internal.jdbc.JdbcNullTypes.IntegerNull; +import static org.flywaydb.core.internal.jdbc.JdbcNullTypes.StringNull; +import org.flywaydb.core.internal.jdbc.JdbcTemplate; + +/** + * @author Kirill Kurdyukov + */ +public class YdbJdbcTemplate extends JdbcTemplate { + + public YdbJdbcTemplate(Connection connection, DatabaseType databaseType) { + super(connection, databaseType); + } + + @Override + protected PreparedStatement prepareStatement(String sql, Object[] params) throws SQLException { + PreparedStatement statement = connection.prepareStatement(sql); + for (int i = 0; i < params.length; i++) { + if (params[i] == StringNull) { + statement.setNull(i + 1, Types.VARCHAR); + } else if (params[i] == IntegerNull) { + statement.setNull(i + 1, Types.INTEGER); + } else if (params[i] == BooleanNull) { + statement.setNull(i + 1, Types.BOOLEAN); + } else { + statement.setObject(i + 1, params[i]); + } + } + + return statement; + } +} diff --git a/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbParser.java b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbParser.java new file mode 100644 index 0000000..b467933 --- /dev/null +++ b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbParser.java @@ -0,0 +1,15 @@ +package org.flywaydb.community.database.ydb; + +import org.flywaydb.core.api.configuration.Configuration; +import org.flywaydb.core.internal.parser.Parser; +import org.flywaydb.core.internal.parser.ParsingContext; + +/** + * @author Kirill Kurdyukov + */ +public class YdbParser extends Parser { + + protected YdbParser(Configuration configuration, ParsingContext parsingContext) { + super(configuration, parsingContext, 3); + } +} diff --git a/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbSchema.java b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbSchema.java new file mode 100644 index 0000000..427d25f --- /dev/null +++ b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbSchema.java @@ -0,0 +1,89 @@ +package org.flywaydb.community.database.ydb; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import org.flywaydb.core.internal.database.base.Schema; +import org.flywaydb.core.internal.jdbc.JdbcTemplate; + +/** + * @author Kirill Kurdyukov + */ +public class YdbSchema extends Schema { + + /** + * @param jdbcTemplate The Jdbc Template for communicating with the DB. + * @param database The database-specific support. + * @param name The name of the schema. + */ + public YdbSchema(JdbcTemplate jdbcTemplate, YdbDatabase database, String name) { + super(jdbcTemplate, database, name); + } + + @Override + protected boolean doExists() { + return true; // Dummy schema exists + } + + @Override + protected boolean doEmpty() throws SQLException { + return doAllTables().length == 0; + } + + @Override + protected void doCreate() { + // Do nothing, YDB doesn't support schemas + } + + @Override + protected void doDrop() { + // Do nothing, YDB doesn't support schemas + } + + @Override + protected void doClean() throws SQLException { + List schemaTables = schemaTables(); + + if (schemaTables.isEmpty()) { + return; + } + + jdbcTemplate.executeStatement( + schemaTables.stream() + .map(table -> "DROP TABLE " + table) + .collect(Collectors.joining("; ")) + ); + } + + @Override + protected YdbTable[] doAllTables() throws SQLException { + return schemaTables().stream() + .map(table -> new YdbTable(jdbcTemplate, database, this, name)) + .toArray(YdbTable[]::new); + } + + @Override + public YdbTable getTable(String tableName) { + return new YdbTable(jdbcTemplate, database, this, tableName); + } + + @Override + public String toString() { + return "ydb_schema"; + } + + private List schemaTables() throws SQLException { + ResultSet rs = jdbcTemplate.getConnection().getMetaData() + .getTables(null, name, null, new String[]{"TABLE"}); + + List tables = new ArrayList<>(); + + while (rs.next()) { + tables.add(database.quote(rs.getString("TABLE_NAME"))); + } + + return tables; + } +} diff --git a/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbTable.java b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbTable.java new file mode 100644 index 0000000..294c109 --- /dev/null +++ b/flyway-database-ydb/src/main/java/org/flywaydb/community/database/ydb/YdbTable.java @@ -0,0 +1,118 @@ +package org.flywaydb.community.database.ydb; + +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.Random; +import java.util.UUID; +import org.flywaydb.core.api.FlywayException; +import org.flywaydb.core.internal.database.base.Table; +import org.flywaydb.core.internal.jdbc.JdbcTemplate; +import org.flywaydb.core.internal.jdbc.Results; + +/** + * @author Kirill Kurdyukov + */ +public class YdbTable extends Table { + + private static final Duration WAIT_LOCK_TIMEOUT = Duration.ofMinutes(2); + private static final int RELEASE_MAX_ATTEMPT = 10; + + private final String tableLockId = UUID.randomUUID() + "-flyway-lock-id"; + private final Random random = new Random(); + + /** + * @param jdbcTemplate The JDBC template for communicating with the YDB. + * @param database The database-specific support. + * @param schema The schema this table lives in. + * @param name The name of the table. + */ + public YdbTable(JdbcTemplate jdbcTemplate, YdbDatabase database, YdbSchema schema, String name) { + super(jdbcTemplate, database, schema, name); + } + + @Override + protected boolean doExists() throws SQLException { + return exists(null, schema, name); + } + + @Override + protected void doLock() { + if (lockDepth > 0) { + // Lock has already been taken - so the relevant row in the table already exists + return; + } + + Instant startLock = Instant.now(); + + do { + if (insertLockingRow()) { + return; + } + + try { + Thread.sleep(100 + random.nextInt(1000)); // pause 0.1s .. 1.1s + } catch (InterruptedException ignored) { + } + } while (startLock.isAfter(Instant.now().minus(WAIT_LOCK_TIMEOUT))); + + throw new FlywayException("Unable to obtain table lock - another Flyway instance may be running"); + } + + @Override + protected void doUnlock() { + if (lockDepth > 1) { + // Leave the locking row alone until we get to the final level of unlocking + + return; + } + + for (int attempt = 0; attempt < RELEASE_MAX_ATTEMPT; attempt++) { + SQLException sqlException = jdbcTemplate + .executeStatement("DELETE FROM " + this + + " WHERE installed_rank = -100 AND version = '" + tableLockId + "'").getException(); + + if (sqlException == null) { + return; + } + + if (attempt == RELEASE_MAX_ATTEMPT - 1) { + throw new FlywayException(sqlException); + } + } + } + + @Override + protected void doDrop() throws SQLException { + jdbcTemplate.execute("DROP TABLE " + database.doQuote(name)); + } + + /** + * Insert the locking row - the primary keys of installed_rank will prevent us having two. + * + * @return true if no errors. + */ + private boolean insertLockingRow() { + Results results = jdbcTemplate.executeStatement( + "INSERT INTO " + this + + "(installed_rank, version, description, type, script, " + + "checksum, installed_by, execution_time, success, installed_on) " + + "VALUES (-100, '" + tableLockId + "', 'flyway-lock', " + + "'', '', 0, '', 0, TRUE, CurrentUtcDatetime())" + ); + + try { + jdbcTemplate.getConnection().commit(); + + // Succeeded if no errors. + return results.getException() == null; + } catch (SQLException e) { + return false; + } + } + + @Override + public String toString() { + return database.doQuote(name); + } +} diff --git a/flyway-database-ydb/src/main/resources/services/org.flywaydb.core.extensibility.Plugin b/flyway-database-ydb/src/main/resources/services/org.flywaydb.core.extensibility.Plugin new file mode 100644 index 0000000..e813b7f --- /dev/null +++ b/flyway-database-ydb/src/main/resources/services/org.flywaydb.core.extensibility.Plugin @@ -0,0 +1 @@ +tech.ydb.flywaydb.database.YdbDatabaseType \ No newline at end of file diff --git a/pom.xml b/pom.xml index 097c008..f0e8f4f 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,7 @@ flyway-database-yugabytedb flyway-database-clickhouse flyway-database-oceanbase + flyway-database-ydb flyway-database-databricks flyway-database-db2zos flyway-community-db-support-archetype