mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 09:40:58 +00:00
[fix] consolidate unreleased 1.9.0 migrations into V190 and fix PG/MySQL startup (#4368)
This commit is contained in:
+1
-1
@@ -48,7 +48,7 @@ class FlywayConfigurationTest {
|
||||
@Test
|
||||
void repairsAndRetriesWhenHistoryHasFailedMigration() {
|
||||
Mockito.when(flyway.migrate())
|
||||
.thenThrow(validateException("Detected failed migration to version 181 (update column)"))
|
||||
.thenThrow(validateException("Detected failed migration to version 190 (update column)"))
|
||||
.thenReturn(null);
|
||||
|
||||
configuration.delayedFlywayInitializer(flyway, enabledProperties());
|
||||
|
||||
@@ -168,6 +168,14 @@
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Test-only driver for the migration regression; the postgresql driver is already
|
||||
on the test classpath transitively. Version comes from the root dependencyManagement
|
||||
and nothing here is packaged into the distribution. -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
-- 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.
|
||||
|
||||
UPDATE hzb_sop_schedule
|
||||
SET enabled = 0
|
||||
WHERE creator IS NULL
|
||||
OR TRIM(creator) = '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_creator_conversation
|
||||
ON hzb_sop_schedule(creator, conversation_id);
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 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.
|
||||
|
||||
-- Enlarge alert define expr to fit rules binding many monitors (#4171)
|
||||
ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr CLOB;
|
||||
+13
-18
@@ -14,23 +14,8 @@
|
||||
-- KIND, either express or implied. See the License for the
|
||||
-- specific language governing permissions and limitations
|
||||
-- under the License.
|
||||
|
||||
-- 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.
|
||||
-- Schema changes for release 1.9.0.
|
||||
-- Every statement below is safe to re-run.
|
||||
|
||||
-- Scheduled SOP execution configurations
|
||||
CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
@@ -39,7 +24,7 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
sop_name VARCHAR(64) NOT NULL COMMENT 'Name of the SOP skill to execute',
|
||||
sop_params VARCHAR(1024) COMMENT 'SOP execution parameters in JSON format',
|
||||
cron_expression VARCHAR(64) NOT NULL COMMENT 'Cron expression for scheduling',
|
||||
enabled TINYINT DEFAULT 1 COMMENT 'Whether the schedule is enabled',
|
||||
enabled BOOLEAN DEFAULT TRUE COMMENT 'Whether the schedule is enabled',
|
||||
last_run_time DATETIME COMMENT 'Last execution time',
|
||||
next_run_time DATETIME COMMENT 'Next scheduled execution time',
|
||||
creator VARCHAR(64) COMMENT 'Creator of this record',
|
||||
@@ -50,3 +35,13 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_conversation_id ON hzb_sop_schedule(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_enabled_next ON hzb_sop_schedule(enabled, next_run_time);
|
||||
-- idx_schedule_creator_conversation is declared by @Index on SopSchedule and created by Hibernate.
|
||||
|
||||
-- Disable SOP schedules that have no owner to scope them to
|
||||
UPDATE hzb_sop_schedule
|
||||
SET enabled = FALSE
|
||||
WHERE creator IS NULL
|
||||
OR TRIM(creator) = '';
|
||||
|
||||
-- Enlarge alert define expr to fit rules binding many monitors
|
||||
ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr CLOB;
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
-- 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.
|
||||
|
||||
UPDATE hzb_sop_schedule
|
||||
SET enabled = 0
|
||||
WHERE creator IS NULL
|
||||
OR TRIM(creator) = '';
|
||||
|
||||
CREATE INDEX idx_schedule_creator_conversation
|
||||
ON hzb_sop_schedule(creator, conversation_id);
|
||||
@@ -1,50 +0,0 @@
|
||||
-- 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.
|
||||
|
||||
-- Enlarge alert define expr to fit rules binding many monitors (#4171)
|
||||
DELIMITER //
|
||||
|
||||
CREATE PROCEDURE ModifyAlertDefineExprColumn()
|
||||
BEGIN
|
||||
DECLARE table_exists INT;
|
||||
DECLARE col_exists INT;
|
||||
|
||||
SELECT COUNT(*) INTO table_exists
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hzb_alert_define';
|
||||
|
||||
IF table_exists = 1 THEN
|
||||
SELECT COUNT(*) INTO col_exists
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'hzb_alert_define'
|
||||
AND COLUMN_NAME = 'expr'
|
||||
AND DATA_TYPE != 'longtext';
|
||||
|
||||
IF col_exists = 1 THEN
|
||||
ALTER TABLE hzb_alert_define MODIFY COLUMN expr LONGTEXT;
|
||||
END IF;
|
||||
END IF;
|
||||
END //
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL ModifyAlertDefineExprColumn();
|
||||
|
||||
DROP PROCEDURE IF EXISTS ModifyAlertDefineExprColumn;
|
||||
|
||||
COMMIT;
|
||||
+46
-18
@@ -14,23 +14,8 @@
|
||||
-- KIND, either express or implied. See the License for the
|
||||
-- specific language governing permissions and limitations
|
||||
-- under the License.
|
||||
|
||||
-- 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.
|
||||
-- Schema changes for release 1.9.0.
|
||||
-- Every statement below is safe to re-run.
|
||||
|
||||
-- Scheduled SOP execution configurations
|
||||
CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
@@ -39,7 +24,7 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
sop_name VARCHAR(64) NOT NULL COMMENT 'Name of the SOP skill to execute',
|
||||
sop_params VARCHAR(1024) COMMENT 'SOP execution parameters in JSON format',
|
||||
cron_expression VARCHAR(64) NOT NULL COMMENT 'Cron expression for scheduling',
|
||||
enabled TINYINT DEFAULT 1 COMMENT 'Whether the schedule is enabled',
|
||||
enabled BOOLEAN DEFAULT TRUE COMMENT 'Whether the schedule is enabled',
|
||||
last_run_time DATETIME COMMENT 'Last execution time',
|
||||
next_run_time DATETIME COMMENT 'Next scheduled execution time',
|
||||
creator VARCHAR(64) COMMENT 'Creator of this record',
|
||||
@@ -49,3 +34,46 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
INDEX idx_schedule_conversation_id (conversation_id),
|
||||
INDEX idx_schedule_enabled_next (enabled, next_run_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
-- idx_schedule_creator_conversation is declared by @Index on SopSchedule and created by Hibernate.
|
||||
|
||||
-- Disable SOP schedules that have no owner to scope them to
|
||||
UPDATE hzb_sop_schedule
|
||||
SET enabled = FALSE
|
||||
WHERE creator IS NULL
|
||||
OR TRIM(creator) = '';
|
||||
|
||||
-- Enlarge alert define expr to fit rules binding many monitors.
|
||||
-- Keep the DELIMITER block last so the routine body cannot swallow the plain
|
||||
-- statements above it.
|
||||
DROP PROCEDURE IF EXISTS ModifyAlertDefineExprColumn;
|
||||
|
||||
DELIMITER //
|
||||
|
||||
CREATE PROCEDURE ModifyAlertDefineExprColumn()
|
||||
BEGIN
|
||||
DECLARE table_exists INT;
|
||||
DECLARE col_exists INT;
|
||||
|
||||
SELECT COUNT(*) INTO table_exists
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hzb_alert_define';
|
||||
|
||||
IF table_exists = 1 THEN
|
||||
SELECT COUNT(*) INTO col_exists
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'hzb_alert_define'
|
||||
AND COLUMN_NAME = 'expr'
|
||||
AND DATA_TYPE != 'longtext';
|
||||
|
||||
IF col_exists = 1 THEN
|
||||
ALTER TABLE hzb_alert_define MODIFY COLUMN expr LONGTEXT;
|
||||
END IF;
|
||||
END IF;
|
||||
END //
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL ModifyAlertDefineExprColumn();
|
||||
|
||||
DROP PROCEDURE IF EXISTS ModifyAlertDefineExprColumn;
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
-- 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.
|
||||
|
||||
UPDATE hzb_sop_schedule
|
||||
SET enabled = 0
|
||||
WHERE creator IS NULL
|
||||
OR BTRIM(creator) = '';
|
||||
|
||||
CREATE INDEX idx_schedule_creator_conversation
|
||||
ON hzb_sop_schedule(creator, conversation_id);
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 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.
|
||||
|
||||
-- Enlarge alert define expr to fit rules binding many monitors (#4171)
|
||||
ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr TYPE TEXT;
|
||||
commit;
|
||||
+13
-18
@@ -14,23 +14,8 @@
|
||||
-- KIND, either express or implied. See the License for the
|
||||
-- specific language governing permissions and limitations
|
||||
-- under the License.
|
||||
|
||||
-- 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.
|
||||
-- Schema changes for release 1.9.0.
|
||||
-- Every statement below is safe to re-run.
|
||||
|
||||
-- Scheduled SOP execution configurations
|
||||
CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
@@ -39,7 +24,7 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
|
||||
sop_name VARCHAR(64) NOT NULL,
|
||||
sop_params VARCHAR(1024),
|
||||
cron_expression VARCHAR(64) NOT NULL,
|
||||
enabled SMALLINT DEFAULT 1,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
last_run_time TIMESTAMP,
|
||||
next_run_time TIMESTAMP,
|
||||
creator VARCHAR(64),
|
||||
@@ -59,3 +44,13 @@ COMMENT ON COLUMN hzb_sop_schedule.next_run_time IS 'Next scheduled execution ti
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_conversation_id ON hzb_sop_schedule(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_enabled_next ON hzb_sop_schedule(enabled, next_run_time);
|
||||
-- idx_schedule_creator_conversation is declared by @Index on SopSchedule and created by Hibernate.
|
||||
|
||||
-- Disable SOP schedules that have no owner to scope them to
|
||||
UPDATE hzb_sop_schedule
|
||||
SET enabled = FALSE
|
||||
WHERE creator IS NULL
|
||||
OR BTRIM(creator) = '';
|
||||
|
||||
-- Enlarge alert define expr to fit rules binding many monitors
|
||||
ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr TYPE TEXT;
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.startup.migration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import javax.sql.DataSource;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationInfo;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.flywaydb.core.api.configuration.FluentConfiguration;
|
||||
import org.flywaydb.core.api.output.MigrateResult;
|
||||
import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.hibernate.SpringImplicitNamingStrategy;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* Upgrade regression for the 1.9.0 migration chain, run once per supported dialect.
|
||||
*
|
||||
* <p>The scenario mirrors a production start: Hibernate {@code ddl-auto} builds the schema
|
||||
* from the entity model first, then Flyway runs, exactly as {@code FlywayConfiguration}
|
||||
* orders them with {@code @DependsOn("entityManagerFactory")}. The database is first taken
|
||||
* to the last officially released state (V180) and only then is V190 applied, so the test
|
||||
* covers the upgrade path a 1.8.0 user actually takes.
|
||||
*/
|
||||
abstract class AbstractMigrationUpgradeTest {
|
||||
|
||||
protected static final String SOP_SCHEDULE_TABLE = "hzb_sop_schedule";
|
||||
|
||||
protected static final String ALERT_DEFINE_TABLE = "hzb_alert_define";
|
||||
|
||||
private static final String LAST_RELEASED_VERSION = "180";
|
||||
|
||||
private static final String CURRENT_VERSION = "190";
|
||||
|
||||
private static final List<String> WITHDRAWN_VERSIONS = List.of("181", "182", "183");
|
||||
|
||||
private static final List<String> SCHEDULE_INDEX_CONVERSATION = List.of("conversation_id");
|
||||
|
||||
private static final List<String> SCHEDULE_INDEX_ENABLED_NEXT = List.of("enabled", "next_run_time");
|
||||
|
||||
private static final List<String> SCHEDULE_INDEX_CREATOR_CONVERSATION = List.of("creator", "conversation_id");
|
||||
|
||||
private static final String SEEDED_EXPR = "usage>90";
|
||||
|
||||
private static final Map<String, Object> SHIPPED_FLYWAY_SETTINGS = loadShippedFlywaySettings();
|
||||
|
||||
/**
|
||||
* The data source of the freshly created database under test.
|
||||
*
|
||||
* @return data source
|
||||
*/
|
||||
protected abstract DataSource dataSource();
|
||||
|
||||
/**
|
||||
* Flyway vendor directory holding the dialect specific migrations.
|
||||
*
|
||||
* @return vendor directory name
|
||||
*/
|
||||
protected abstract String vendor();
|
||||
|
||||
/**
|
||||
* Reads the type the database itself reports for a column.
|
||||
*
|
||||
* @param table table name
|
||||
* @param column column name
|
||||
* @return the declared type, normalised to lower case
|
||||
* @throws SQLException on query failure
|
||||
*/
|
||||
protected abstract String declaredType(String table, String column) throws SQLException;
|
||||
|
||||
/**
|
||||
* The types this dialect uses for a boolean column. More than one spelling is legitimate:
|
||||
* on MySQL Hibernate declares {@code bit(1)} while the migration's own {@code BOOLEAN}
|
||||
* lands as {@code tinyint(1)}, and both hold the same values.
|
||||
*
|
||||
* @return accepted types of {@code hzb_sop_schedule.enabled}
|
||||
*/
|
||||
protected abstract Set<String> expectedBooleanTypes();
|
||||
|
||||
/**
|
||||
* The type {@code hzb_alert_define.expr} must have once V190 has enlarged it.
|
||||
*
|
||||
* @return expected type of {@code hzb_alert_define.expr}
|
||||
*/
|
||||
protected abstract String expectedEnlargedTextType();
|
||||
|
||||
/**
|
||||
* Stored routines left behind in the schema under test.
|
||||
*
|
||||
* @return routine names, empty when the migrations cleaned up after themselves
|
||||
* @throws SQLException on query failure
|
||||
*/
|
||||
protected abstract List<String> storedRoutines() throws SQLException;
|
||||
|
||||
@Test
|
||||
@DisplayName("V190 upgrades a V180 database whose schedule table Hibernate owns")
|
||||
void upgradesDatabaseWhoseScheduleTableHibernateOwns() throws SQLException {
|
||||
startFromLastReleasedState();
|
||||
seedSchedules();
|
||||
seedAlertDefine();
|
||||
|
||||
Flyway flyway = flyway();
|
||||
assertPendingVersions(flyway, List.of(CURRENT_VERSION));
|
||||
|
||||
MigrateResult result = flyway.migrate();
|
||||
assertEquals(1, result.migrationsExecuted, "V190 must be the only migration left to apply");
|
||||
assertEquals(CURRENT_VERSION, result.targetSchemaVersion);
|
||||
|
||||
assertScheduleRowsScopedToOwner();
|
||||
assertBooleanColumn();
|
||||
assertEquals(expectedEnlargedTextType(), declaredType(ALERT_DEFINE_TABLE, "expr"));
|
||||
assertSeededExprSurvived();
|
||||
assertOversizedExprRoundTrips();
|
||||
assertScheduleIndexes(List.of(
|
||||
SCHEDULE_INDEX_CONVERSATION,
|
||||
SCHEDULE_INDEX_ENABLED_NEXT,
|
||||
SCHEDULE_INDEX_CREATOR_CONVERSATION));
|
||||
assertNoStoredRoutinesLeftBehind();
|
||||
assertHistoryChain();
|
||||
|
||||
assertSecondStartupChangesNothing();
|
||||
assertScheduleRowsScopedToOwner();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V190 upgrades a V180 database whose schedule table the migration has to create")
|
||||
void upgradesDatabaseWhoseScheduleTableTheMigrationCreates() throws SQLException {
|
||||
startFromLastReleasedState();
|
||||
// Model the ordering where Flyway sees the table first: the migration, not Hibernate,
|
||||
// has to create hzb_sop_schedule. This is the path that made PostgreSQL declare the
|
||||
// column SMALLINT, after which the script's own "SET enabled = FALSE" failed with 42804.
|
||||
execute("DROP TABLE " + SOP_SCHEDULE_TABLE);
|
||||
|
||||
Flyway flyway = flyway();
|
||||
MigrateResult result = flyway.migrate();
|
||||
assertEquals(1, result.migrationsExecuted, "V190 must be the only migration left to apply");
|
||||
|
||||
assertBooleanColumn();
|
||||
assertEquals(expectedEnlargedTextType(), declaredType(ALERT_DEFINE_TABLE, "expr"));
|
||||
assertScheduleIndexes(List.of(SCHEDULE_INDEX_CONVERSATION, SCHEDULE_INDEX_ENABLED_NEXT));
|
||||
assertNoStoredRoutinesLeftBehind();
|
||||
assertHistoryChain();
|
||||
|
||||
// The migration ran against an empty table, so replay its own update statement to prove
|
||||
// the column it created accepts the FALSE literal the script uses.
|
||||
seedSchedules();
|
||||
execute("UPDATE " + SOP_SCHEDULE_TABLE + " SET enabled = FALSE WHERE creator IS NULL OR TRIM(creator) = ''");
|
||||
assertScheduleRowsScopedToOwner();
|
||||
|
||||
assertSecondStartupChangesNothing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the database to the state a 1.8.0 installation is in: schema built by Hibernate,
|
||||
* migration history applied up to and including the last released version.
|
||||
*/
|
||||
private void startFromLastReleasedState() {
|
||||
createSchemaWithHibernate();
|
||||
MigrateResult result = flyway(LAST_RELEASED_VERSION).migrate();
|
||||
assertEquals(LAST_RELEASED_VERSION, result.targetSchemaVersion,
|
||||
"the released baseline must stop at V" + LAST_RELEASED_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the schema the way the application does at startup, from the scanned entity model.
|
||||
*/
|
||||
private void createSchemaWithHibernate() {
|
||||
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
|
||||
factory.setDataSource(dataSource());
|
||||
factory.setPackagesToScan("org.apache.hertzbeat");
|
||||
factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("hibernate.hbm2ddl.auto", "update");
|
||||
// The naming strategies Spring Boot applies by default, so the generated column names
|
||||
// are the ones the running application has.
|
||||
properties.setProperty("hibernate.implicit_naming_strategy", SpringImplicitNamingStrategy.class.getName());
|
||||
properties.setProperty("hibernate.physical_naming_strategy",
|
||||
CamelCaseToUnderscoresNamingStrategy.class.getName());
|
||||
factory.setJpaProperties(properties);
|
||||
factory.afterPropertiesSet();
|
||||
factory.destroy();
|
||||
}
|
||||
|
||||
private Flyway flyway() {
|
||||
return flyway(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures Flyway from the shipped {@code application.yml} rather than from a copy of it,
|
||||
* so the regression cannot drift away from what the application actually runs. The
|
||||
* {@code {vendor}} placeholder is resolved the way Spring Boot resolves it at startup.
|
||||
*
|
||||
* @param target version to stop at, {@code null} to migrate to the latest
|
||||
* @return configured Flyway instance
|
||||
*/
|
||||
private Flyway flyway(String target) {
|
||||
List<String> locations = shippedSetting("locations", List.class).stream()
|
||||
.map(location -> String.valueOf(location).replace("{vendor}", vendor()))
|
||||
.toList();
|
||||
FluentConfiguration configuration = Flyway.configure()
|
||||
.dataSource(dataSource())
|
||||
.locations(locations.toArray(new String[0]))
|
||||
.baselineOnMigrate(shippedSetting("baseline-on-migrate", Boolean.class))
|
||||
.baselineVersion(String.valueOf(shippedSetting("baseline-version", Object.class)))
|
||||
.cleanDisabled(shippedSetting("clean-disabled", Boolean.class));
|
||||
if (target != null) {
|
||||
configuration.target(MigrationVersion.fromVersion(target));
|
||||
}
|
||||
return configuration.load();
|
||||
}
|
||||
|
||||
private static <T> T shippedSetting(String key, Class<T> type) {
|
||||
Object value = SHIPPED_FLYWAY_SETTINGS.get(key);
|
||||
assertNotNull(value, () -> "spring.flyway." + key + " is not declared in application.yml");
|
||||
return type.cast(value);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> loadShippedFlywaySettings() {
|
||||
try (InputStream stream = AbstractMigrationUpgradeTest.class.getClassLoader()
|
||||
.getResourceAsStream("application.yml")) {
|
||||
assertNotNull(stream, "application.yml must be on the test classpath");
|
||||
for (Object document : new Yaml().loadAll(stream)) {
|
||||
if (!(document instanceof Map<?, ?> root)) {
|
||||
continue;
|
||||
}
|
||||
Object spring = root.get("spring");
|
||||
if (spring instanceof Map<?, ?> springSection
|
||||
&& springSection.get("flyway") instanceof Map<?, ?> flyway) {
|
||||
return (Map<String, Object>) flyway;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("cannot read the shipped application.yml", e);
|
||||
}
|
||||
throw new IllegalStateException("application.yml declares no spring.flyway section");
|
||||
}
|
||||
|
||||
private void assertPendingVersions(Flyway flyway, List<String> expected) {
|
||||
List<String> pending = new ArrayList<>();
|
||||
for (MigrationInfo info : flyway.info().pending()) {
|
||||
pending.add(info.getVersion().getVersion());
|
||||
}
|
||||
assertEquals(expected, pending);
|
||||
}
|
||||
|
||||
private void assertSecondStartupChangesNothing() {
|
||||
Flyway restart = flyway();
|
||||
assertEquals(0, restart.info().pending().length, "a restart must have nothing left to apply");
|
||||
MigrateResult result = restart.migrate();
|
||||
assertEquals(0, result.migrationsExecuted, "a restart must not re-run any migration");
|
||||
assertTrue(restart.validateWithResult().validationSuccessful,
|
||||
"a restart must validate cleanly against the recorded history");
|
||||
}
|
||||
|
||||
private void assertHistoryChain() {
|
||||
List<String> applied = new ArrayList<>();
|
||||
for (MigrationInfo info : flyway().info().applied()) {
|
||||
assertFalse(info.getState().isFailed(),
|
||||
() -> "migration " + info.getVersion() + " is recorded as failed");
|
||||
if (info.getVersion() != null) {
|
||||
applied.add(info.getVersion().getVersion());
|
||||
}
|
||||
}
|
||||
assertEquals(1, applied.stream().filter(CURRENT_VERSION::equals).count(),
|
||||
() -> "V" + CURRENT_VERSION + " must be recorded exactly once, history is " + applied);
|
||||
for (String withdrawn : WITHDRAWN_VERSIONS) {
|
||||
assertTrue(applied.stream().noneMatch(withdrawn::equals),
|
||||
() -> "withdrawn version V" + withdrawn + " must not appear in the history, history is " + applied);
|
||||
}
|
||||
assertEquals(CURRENT_VERSION, applied.get(applied.size() - 1),
|
||||
() -> "the chain must end at V" + CURRENT_VERSION + ", history is " + applied);
|
||||
}
|
||||
|
||||
private void seedSchedules() throws SQLException {
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("INSERT INTO " + SOP_SCHEDULE_TABLE
|
||||
+ " (conversation_id, sop_name, cron_expression, enabled, creator) VALUES (?, ?, ?, ?, ?)")) {
|
||||
addSchedule(statement, 1L, "owned", "alice");
|
||||
addSchedule(statement, 2L, "null-owner", null);
|
||||
addSchedule(statement, 3L, "blank-owner", " ");
|
||||
statement.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
private void addSchedule(PreparedStatement statement, long conversationId, String name, String creator)
|
||||
throws SQLException {
|
||||
statement.setLong(1, conversationId);
|
||||
statement.setString(2, name);
|
||||
statement.setString(3, "0 0 * * * ?");
|
||||
statement.setBoolean(4, true);
|
||||
statement.setString(5, creator);
|
||||
statement.addBatch();
|
||||
}
|
||||
|
||||
private void assertScheduleRowsScopedToOwner() throws SQLException {
|
||||
Map<String, Boolean> enabledByName = new LinkedHashMap<>();
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet rs = statement.executeQuery("SELECT sop_name, enabled FROM " + SOP_SCHEDULE_TABLE)) {
|
||||
while (rs.next()) {
|
||||
enabledByName.put(rs.getString("sop_name"), rs.getBoolean("enabled"));
|
||||
}
|
||||
}
|
||||
assertEquals(Boolean.TRUE, enabledByName.get("owned"), "a schedule with an owner must stay enabled");
|
||||
assertEquals(Boolean.FALSE, enabledByName.get("null-owner"), "a schedule without an owner must be disabled");
|
||||
assertEquals(Boolean.FALSE, enabledByName.get("blank-owner"), "a blank owner must count as no owner");
|
||||
}
|
||||
|
||||
private void seedAlertDefine() throws SQLException {
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("INSERT INTO " + ALERT_DEFINE_TABLE
|
||||
+ " (name, expr, enable) VALUES (?, ?, ?)")) {
|
||||
statement.setString(1, "seeded");
|
||||
statement.setString(2, SEEDED_EXPR);
|
||||
statement.setBoolean(3, true);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertSeededExprSurvived() throws SQLException {
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT expr FROM " + ALERT_DEFINE_TABLE + " WHERE name = ?")) {
|
||||
statement.setString(1, "seeded");
|
||||
try (ResultSet rs = statement.executeQuery()) {
|
||||
assertTrue(rs.next(), "the alert define seeded before the upgrade must still be there");
|
||||
assertEquals(SEEDED_EXPR, rs.getString("expr"), "widening expr must not rewrite existing rows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertOversizedExprRoundTrips() throws SQLException {
|
||||
String expr = "a".repeat(100_000);
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
PreparedStatement insert = connection.prepareStatement("INSERT INTO " + ALERT_DEFINE_TABLE
|
||||
+ " (name, expr, enable) VALUES (?, ?, ?)")) {
|
||||
insert.setString(1, "oversized");
|
||||
insert.setString(2, expr);
|
||||
insert.setBoolean(3, true);
|
||||
insert.executeUpdate();
|
||||
}
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
PreparedStatement select = connection.prepareStatement(
|
||||
"SELECT expr FROM " + ALERT_DEFINE_TABLE + " WHERE name = ?")) {
|
||||
select.setString(1, "oversized");
|
||||
try (ResultSet rs = select.executeQuery()) {
|
||||
assertTrue(rs.next(), "the oversized alert define must have been stored");
|
||||
assertEquals(expr.length(), rs.getString("expr").length(),
|
||||
"expr must hold an expression far beyond the pre-1.9.0 limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertBooleanColumn() throws SQLException {
|
||||
String actual = declaredType(SOP_SCHEDULE_TABLE, "enabled");
|
||||
assertTrue(expectedBooleanTypes().contains(actual),
|
||||
() -> "enabled must be a boolean column, but is declared " + actual
|
||||
+ "; expected one of " + expectedBooleanTypes());
|
||||
}
|
||||
|
||||
private void assertNoStoredRoutinesLeftBehind() throws SQLException {
|
||||
List<String> routines = storedRoutines();
|
||||
assertTrue(routines.isEmpty(), () -> "migrations left stored routines behind: " + routines);
|
||||
}
|
||||
|
||||
private void assertScheduleIndexes(List<List<String>> expected) throws SQLException {
|
||||
Map<String, List<String>> indexes = indexColumns(SOP_SCHEDULE_TABLE);
|
||||
List<List<String>> actual = indexes.values().stream()
|
||||
.filter(columns -> !columns.equals(List.of("id")))
|
||||
.toList();
|
||||
for (List<String> signature : expected) {
|
||||
assertEquals(1, actual.stream().filter(signature::equals).count(),
|
||||
() -> "index on " + signature + " must exist exactly once, found " + indexes);
|
||||
}
|
||||
assertEquals(expected.size(), actual.size(), () -> "unexpected indexes on " + SOP_SCHEDULE_TABLE + ": " + indexes);
|
||||
}
|
||||
|
||||
private Map<String, List<String>> indexColumns(String table) throws SQLException {
|
||||
Map<String, List<String>> indexes = new LinkedHashMap<>();
|
||||
try (Connection connection = dataSource().getConnection()) {
|
||||
String[] identity = resolveTable(connection, table);
|
||||
assertNotNull(identity, () -> "table " + table + " does not exist");
|
||||
try (ResultSet rs = connection.getMetaData()
|
||||
.getIndexInfo(identity[0], identity[1], identity[2], false, false)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("INDEX_NAME");
|
||||
String column = rs.getString("COLUMN_NAME");
|
||||
if (name == null || column == null) {
|
||||
continue;
|
||||
}
|
||||
indexes.computeIfAbsent(name.toLowerCase(Locale.ROOT), key -> new ArrayList<>())
|
||||
.add(column.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the catalog, schema and stored spelling of a table. The lookup is pinned to the
|
||||
* catalog and schema of the connection: MySQL Connector/J defaults {@code nullCatalogMeansCurrent}
|
||||
* to false, so an unqualified lookup would happily match a table of the same name in another
|
||||
* database on the same server.
|
||||
*
|
||||
* @param connection connection to the database under test
|
||||
* @param table table name, in any case
|
||||
* @return catalog, schema and table name, or {@code null} when the table does not exist
|
||||
* @throws SQLException on metadata failure
|
||||
*/
|
||||
private String[] resolveTable(Connection connection, String table) throws SQLException {
|
||||
String catalog = connection.getCatalog();
|
||||
String schema = connection.getSchema();
|
||||
try (ResultSet rs = connection.getMetaData().getTables(catalog, schema, "%", new String[]{"TABLE"})) {
|
||||
while (rs.next()) {
|
||||
if (table.equalsIgnoreCase(rs.getString("TABLE_NAME"))) {
|
||||
return new String[]{rs.getString("TABLE_CAT"), rs.getString("TABLE_SCHEM"),
|
||||
rs.getString("TABLE_NAME")};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a statement against the database under test.
|
||||
*
|
||||
* @param sql statement to run
|
||||
* @throws SQLException on execution failure
|
||||
*/
|
||||
protected void execute(String sql) throws SQLException {
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single value query against the database under test.
|
||||
*
|
||||
* @param sql query returning one column
|
||||
* @param parameters query parameters
|
||||
* @return the first column of the first row, lower cased, or {@code null} when there is no row
|
||||
* @throws SQLException on execution failure
|
||||
*/
|
||||
protected String queryString(String sql, Object... parameters) throws SQLException {
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int index = 0; index < parameters.length; index++) {
|
||||
statement.setObject(index + 1, parameters[index]);
|
||||
}
|
||||
try (ResultSet rs = statement.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
return null;
|
||||
}
|
||||
String value = rs.getString(1);
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a query collecting a single string column.
|
||||
*
|
||||
* @param sql query returning one column
|
||||
* @return every value of the first column
|
||||
* @throws SQLException on execution failure
|
||||
*/
|
||||
protected List<String> queryStrings(String sql) throws SQLException {
|
||||
List<String> values = new ArrayList<>();
|
||||
try (Connection connection = dataSource().getConnection();
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet rs = statement.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
values.add(rs.getString(1));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the container database accepts JDBC connections. MySQL briefly listens while
|
||||
* its entrypoint is still bootstrapping, so a single successful connect is not enough.
|
||||
*
|
||||
* @param url jdbc url
|
||||
* @param username user name
|
||||
* @param password password
|
||||
*/
|
||||
protected static void awaitDatabaseReady(String url, String username, String password) {
|
||||
long deadline = System.currentTimeMillis() + Duration.ofMinutes(5).toMillis();
|
||||
int consecutiveSuccesses = 0;
|
||||
SQLException last = null;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try (Connection connection = DriverManager.getConnection(url, username, password);
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("SELECT 1");
|
||||
if (++consecutiveSuccesses == 3) {
|
||||
return;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
last = e;
|
||||
consecutiveSuccesses = 0;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(1000L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("interrupted while waiting for the database", e);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("timed out waiting for " + url, last);
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.startup.migration;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
/**
|
||||
* Runs the 1.9.0 migration upgrade regression on H2, the default embedded database.
|
||||
*/
|
||||
@DisplayName("1.9.0 migration upgrade on H2")
|
||||
class H2MigrationUpgradeTest extends AbstractMigrationUpgradeTest {
|
||||
|
||||
private static final AtomicInteger DATABASE_SEQUENCE = new AtomicInteger();
|
||||
|
||||
private DriverManagerDataSource dataSource;
|
||||
|
||||
@BeforeEach
|
||||
void createDatabase() {
|
||||
// MODE=MYSQL matches the URL the shipped application.yml uses.
|
||||
dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:migration_" + DATABASE_SEQUENCE.incrementAndGet() + ";MODE=MYSQL;DB_CLOSE_DELAY=-1",
|
||||
"sa", "");
|
||||
dataSource.setDriverClassName("org.h2.Driver");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void dropDatabase() throws SQLException {
|
||||
execute("SHUTDOWN");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DataSource dataSource() {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String vendor() {
|
||||
return "h2";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String declaredType(String table, String column) throws SQLException {
|
||||
return queryString("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
+ "WHERE TABLE_NAME = ? AND COLUMN_NAME = ?",
|
||||
table.toUpperCase(Locale.ROOT), column.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> expectedBooleanTypes() {
|
||||
return Set.of("boolean");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String expectedEnlargedTextType() {
|
||||
return "character large object";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<String> storedRoutines() throws SQLException {
|
||||
return queryStrings("SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = 'PUBLIC'");
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.startup.migration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.io.File;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Guards the shape of the Flyway migration chain shipped by the startup module.
|
||||
* The chain is part of the release contract: 1.8.0 ended at V180 and 1.9.0 adds
|
||||
* exactly one migration per dialect, V190.
|
||||
*/
|
||||
class MigrationInventoryTest {
|
||||
|
||||
private static final List<String> VENDORS = List.of("h2", "mysql", "postgresql");
|
||||
|
||||
private static final Pattern VERSIONED_SCRIPT = Pattern.compile("^V(\\d+)__.+\\.sql$");
|
||||
|
||||
private static final int LAST_RELEASED_VERSION = 180;
|
||||
|
||||
private static final int CURRENT_VERSION = 190;
|
||||
|
||||
private static final List<Integer> WITHDRAWN_VERSIONS = List.of(181, 182, 183);
|
||||
|
||||
@Test
|
||||
@DisplayName("every dialect ends the chain with V180 followed by V190")
|
||||
void chainEndsWithLastReleasedVersionThenCurrentVersion() throws URISyntaxException {
|
||||
for (String vendor : VENDORS) {
|
||||
List<Integer> versions = versionsOf(vendor);
|
||||
|
||||
assertTrue(versions.size() >= 2, () -> vendor + " must ship at least V180 and V190");
|
||||
assertEquals(CURRENT_VERSION, versions.get(versions.size() - 1),
|
||||
() -> vendor + " must end at V" + CURRENT_VERSION + " but ends at " + versions);
|
||||
assertEquals(LAST_RELEASED_VERSION, versions.get(versions.size() - 2),
|
||||
() -> vendor + " must apply V" + CURRENT_VERSION + " directly after the last released V"
|
||||
+ LAST_RELEASED_VERSION + " but has " + versions);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the withdrawn pre-release versions V181, V182 and V183 are gone")
|
||||
void withdrawnVersionsAreAbsent() throws URISyntaxException {
|
||||
for (String vendor : VENDORS) {
|
||||
List<Integer> versions = versionsOf(vendor);
|
||||
for (Integer withdrawn : WITHDRAWN_VERSIONS) {
|
||||
assertFalse(versions.contains(withdrawn),
|
||||
() -> vendor + " still ships the withdrawn pre-release migration V" + withdrawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all dialects ship the same versions, each exactly once")
|
||||
void allDialectsShipTheSameVersions() throws URISyntaxException {
|
||||
Map<String, List<Integer>> byVendor = new LinkedHashMap<>();
|
||||
for (String vendor : VENDORS) {
|
||||
List<Integer> versions = versionsOf(vendor);
|
||||
assertEquals(versions.size(), versions.stream().distinct().count(),
|
||||
() -> vendor + " declares a version more than once: " + versions);
|
||||
byVendor.put(vendor, versions);
|
||||
}
|
||||
|
||||
List<Integer> reference = byVendor.get(VENDORS.get(0));
|
||||
for (Map.Entry<String, List<Integer>> entry : byVendor.entrySet()) {
|
||||
assertEquals(reference, entry.getValue(),
|
||||
() -> entry.getKey() + " diverges from " + VENDORS.get(0) + ": "
|
||||
+ entry.getValue() + " vs " + reference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the migration versions of one dialect from the packaged classpath location,
|
||||
* which is exactly what {@code spring.flyway.locations} resolves at runtime.
|
||||
*
|
||||
* @param vendor flyway vendor directory name
|
||||
* @return the versions in ascending order
|
||||
* @throws URISyntaxException when the classpath location cannot be resolved to a directory
|
||||
*/
|
||||
private static List<Integer> versionsOf(String vendor) throws URISyntaxException {
|
||||
URL location = MigrationInventoryTest.class.getClassLoader().getResource("db/migration/" + vendor);
|
||||
assertNotNull(location, () -> "missing migration location db/migration/" + vendor);
|
||||
|
||||
File[] files = new File(location.toURI()).listFiles();
|
||||
assertNotNull(files, () -> "db/migration/" + vendor + " is not a readable directory");
|
||||
|
||||
List<Integer> versions = new ArrayList<>();
|
||||
for (File file : files) {
|
||||
Matcher matcher = VERSIONED_SCRIPT.matcher(file.getName());
|
||||
if (matcher.matches()) {
|
||||
versions.add(Integer.valueOf(matcher.group(1)));
|
||||
}
|
||||
}
|
||||
versions.sort(Integer::compareTo);
|
||||
return List.copyOf(versions);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no dialect directory holds an unversioned or misnamed script")
|
||||
void everyScriptFollowsTheVersionedNaming() throws URISyntaxException {
|
||||
for (String vendor : VENDORS) {
|
||||
URL location = MigrationInventoryTest.class.getClassLoader().getResource("db/migration/" + vendor);
|
||||
assertNotNull(location, () -> "missing migration location db/migration/" + vendor);
|
||||
File[] files = new File(location.toURI()).listFiles();
|
||||
assertNotNull(files, () -> "db/migration/" + vendor + " is not a readable directory");
|
||||
|
||||
List<String> unexpected = Arrays.stream(files)
|
||||
.map(File::getName)
|
||||
.filter(name -> !VERSIONED_SCRIPT.matcher(name).matches())
|
||||
.toList();
|
||||
assertTrue(unexpected.isEmpty(),
|
||||
() -> "db/migration/" + vendor + " holds non-migration files: " + unexpected);
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.startup.migration;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Runs the 1.9.0 migration upgrade regression on MySQL, where the pre-release scripts
|
||||
* failed with {@code 1061 Duplicate key name}. Requires a Docker daemon, which the
|
||||
* backend CI runner provides.
|
||||
*/
|
||||
@DisplayName("1.9.0 migration upgrade on MySQL")
|
||||
class MysqlMigrationUpgradeTest extends AbstractMigrationUpgradeTest {
|
||||
|
||||
private static final DockerImageName IMAGE = DockerImageName.parse("mysql:8.4");
|
||||
|
||||
private static final String ROOT_USER = "root";
|
||||
|
||||
private static final String ROOT_PASSWORD = "root123";
|
||||
|
||||
private static final int MYSQL_PORT = 3306;
|
||||
|
||||
private static final AtomicInteger DATABASE_SEQUENCE = new AtomicInteger();
|
||||
|
||||
private static GenericContainer<?> container;
|
||||
|
||||
private DriverManagerDataSource dataSource;
|
||||
|
||||
@BeforeAll
|
||||
static void startContainer() {
|
||||
assumeTrue(DockerClientFactory.instance().isDockerAvailable(),
|
||||
"a Docker daemon is required to verify the MySQL migrations");
|
||||
container = new GenericContainer<>(IMAGE)
|
||||
.withExposedPorts(MYSQL_PORT)
|
||||
.withEnv("MYSQL_ROOT_PASSWORD", ROOT_PASSWORD)
|
||||
.waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(5)));
|
||||
container.start();
|
||||
awaitDatabaseReady(jdbcUrl("mysql"), ROOT_USER, ROOT_PASSWORD);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopContainer() {
|
||||
if (container != null) {
|
||||
container.stop();
|
||||
container = null;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void createDatabase() throws SQLException {
|
||||
String database = "hertzbeat_migration_" + DATABASE_SEQUENCE.incrementAndGet();
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl("mysql"), ROOT_USER, ROOT_PASSWORD);
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE DATABASE " + database);
|
||||
}
|
||||
dataSource = new DriverManagerDataSource(jdbcUrl(database), ROOT_USER, ROOT_PASSWORD);
|
||||
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
|
||||
}
|
||||
|
||||
private static String jdbcUrl(String database) {
|
||||
return "jdbc:mysql://" + container.getHost() + ":" + container.getMappedPort(MYSQL_PORT) + "/" + database
|
||||
+ "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DataSource dataSource() {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String vendor() {
|
||||
return "mysql";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String declaredType(String table, String column) throws SQLException {
|
||||
return queryString("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", table, column);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> expectedBooleanTypes() {
|
||||
// Hibernate declares bit(1); the migration's BOOLEAN lands as tinyint(1).
|
||||
return Set.of("tinyint(1)", "bit(1)");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String expectedEnlargedTextType() {
|
||||
return "longtext";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<String> storedRoutines() throws SQLException {
|
||||
return queryStrings("SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()");
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.startup.migration;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Runs the 1.9.0 migration upgrade regression on PostgreSQL, where the pre-release scripts
|
||||
* failed with {@code 42804 column "enabled" is of type boolean but expression is of type
|
||||
* integer}. Requires a Docker daemon, which the backend CI runner provides.
|
||||
*/
|
||||
@DisplayName("1.9.0 migration upgrade on PostgreSQL")
|
||||
class PostgresqlMigrationUpgradeTest extends AbstractMigrationUpgradeTest {
|
||||
|
||||
private static final DockerImageName IMAGE = DockerImageName.parse("postgres:17-alpine");
|
||||
|
||||
private static final String USERNAME = "postgres";
|
||||
|
||||
private static final String PASSWORD = "postgres123";
|
||||
|
||||
private static final int POSTGRESQL_PORT = 5432;
|
||||
|
||||
private static final AtomicInteger DATABASE_SEQUENCE = new AtomicInteger();
|
||||
|
||||
private static GenericContainer<?> container;
|
||||
|
||||
private DriverManagerDataSource dataSource;
|
||||
|
||||
@BeforeAll
|
||||
static void startContainer() {
|
||||
assumeTrue(DockerClientFactory.instance().isDockerAvailable(),
|
||||
"a Docker daemon is required to verify the PostgreSQL migrations");
|
||||
container = new GenericContainer<>(IMAGE)
|
||||
.withExposedPorts(POSTGRESQL_PORT)
|
||||
.withEnv("POSTGRES_PASSWORD", PASSWORD)
|
||||
.waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(5)));
|
||||
container.start();
|
||||
awaitDatabaseReady(jdbcUrl("postgres"), USERNAME, PASSWORD);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopContainer() {
|
||||
if (container != null) {
|
||||
container.stop();
|
||||
container = null;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void createDatabase() throws SQLException {
|
||||
String database = "hertzbeat_migration_" + DATABASE_SEQUENCE.incrementAndGet();
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl("postgres"), USERNAME, PASSWORD);
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE DATABASE " + database);
|
||||
}
|
||||
dataSource = new DriverManagerDataSource(jdbcUrl(database), USERNAME, PASSWORD);
|
||||
dataSource.setDriverClassName("org.postgresql.Driver");
|
||||
}
|
||||
|
||||
private static String jdbcUrl(String database) {
|
||||
return "jdbc:postgresql://" + container.getHost() + ":" + container.getMappedPort(POSTGRESQL_PORT)
|
||||
+ "/" + database;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DataSource dataSource() {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String vendor() {
|
||||
return "postgresql";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String declaredType(String table, String column) throws SQLException {
|
||||
return queryString("SELECT udt_name FROM information_schema.columns "
|
||||
+ "WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", table, column);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> expectedBooleanTypes() {
|
||||
return Set.of("bool");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String expectedEnlargedTextType() {
|
||||
return "text";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<String> storedRoutines() throws SQLException {
|
||||
return queryStrings("SELECT routine_name FROM information_schema.routines "
|
||||
+ "WHERE routine_schema = current_schema()");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user