diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java index b57d1a8a6f..f8a1067a80 100644 --- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java @@ -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()); diff --git a/hertzbeat-startup/pom.xml b/hertzbeat-startup/pom.xml index 90d3a3a663..eb512ba049 100644 --- a/hertzbeat-startup/pom.xml +++ b/hertzbeat-startup/pom.xml @@ -168,6 +168,14 @@ ${testcontainers.version} test + + + com.mysql + mysql-connector-j + test + diff --git a/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql b/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql deleted file mode 100644 index a0aa6a82d8..0000000000 --- a/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql +++ /dev/null @@ -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); diff --git a/hertzbeat-startup/src/main/resources/db/migration/h2/V183__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/h2/V183__update_column.sql deleted file mode 100644 index 936fbf6547..0000000000 --- a/hertzbeat-startup/src/main/resources/db/migration/h2/V183__update_column.sql +++ /dev/null @@ -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; diff --git a/hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/h2/V190__update_column.sql similarity index 68% rename from hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql rename to hertzbeat-startup/src/main/resources/db/migration/h2/V190__update_column.sql index aa700f83f4..e018f8151c 100644 --- a/hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql +++ b/hertzbeat-startup/src/main/resources/db/migration/h2/V190__update_column.sql @@ -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; diff --git a/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql b/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql deleted file mode 100644 index 3d0a986ea1..0000000000 --- a/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql +++ /dev/null @@ -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); diff --git a/hertzbeat-startup/src/main/resources/db/migration/mysql/V183__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/mysql/V183__update_column.sql deleted file mode 100644 index aeb22f273a..0000000000 --- a/hertzbeat-startup/src/main/resources/db/migration/mysql/V183__update_column.sql +++ /dev/null @@ -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; diff --git a/hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/mysql/V190__update_column.sql similarity index 56% rename from hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql rename to hertzbeat-startup/src/main/resources/db/migration/mysql/V190__update_column.sql index d45a51231d..b90363a648 100644 --- a/hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql +++ b/hertzbeat-startup/src/main/resources/db/migration/mysql/V190__update_column.sql @@ -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; diff --git a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql deleted file mode 100644 index 44aaf71220..0000000000 --- a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql +++ /dev/null @@ -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); diff --git a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V183__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V183__update_column.sql deleted file mode 100644 index 08c429718e..0000000000 --- a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V183__update_column.sql +++ /dev/null @@ -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; diff --git a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V190__update_column.sql similarity index 72% rename from hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql rename to hertzbeat-startup/src/main/resources/db/migration/postgresql/V190__update_column.sql index 8604cb9e75..2c8334205e 100644 --- a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql +++ b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V190__update_column.sql @@ -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; diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/AbstractMigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/AbstractMigrationUpgradeTest.java new file mode 100644 index 0000000000..870f383f3e --- /dev/null +++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/AbstractMigrationUpgradeTest.java @@ -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. + * + *

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 WITHDRAWN_VERSIONS = List.of("181", "182", "183"); + + private static final List SCHEDULE_INDEX_CONVERSATION = List.of("conversation_id"); + + private static final List SCHEDULE_INDEX_ENABLED_NEXT = List.of("enabled", "next_run_time"); + + private static final List SCHEDULE_INDEX_CREATOR_CONVERSATION = List.of("creator", "conversation_id"); + + private static final String SEEDED_EXPR = "usage>90"; + + private static final Map 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 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 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 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 shippedSetting(String key, Class 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 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) 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 expected) { + List 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 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 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 routines = storedRoutines(); + assertTrue(routines.isEmpty(), () -> "migrations left stored routines behind: " + routines); + } + + private void assertScheduleIndexes(List> expected) throws SQLException { + Map> indexes = indexColumns(SOP_SCHEDULE_TABLE); + List> actual = indexes.values().stream() + .filter(columns -> !columns.equals(List.of("id"))) + .toList(); + for (List 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> indexColumns(String table) throws SQLException { + Map> 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 queryStrings(String sql) throws SQLException { + List 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); + } +} diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/H2MigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/H2MigrationUpgradeTest.java new file mode 100644 index 0000000000..45ac3b19b7 --- /dev/null +++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/H2MigrationUpgradeTest.java @@ -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 expectedBooleanTypes() { + return Set.of("boolean"); + } + + @Override + protected String expectedEnlargedTextType() { + return "character large object"; + } + + @Override + protected List storedRoutines() throws SQLException { + return queryStrings("SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = 'PUBLIC'"); + } +} diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MigrationInventoryTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MigrationInventoryTest.java new file mode 100644 index 0000000000..9fd93b494f --- /dev/null +++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MigrationInventoryTest.java @@ -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 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 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 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 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> byVendor = new LinkedHashMap<>(); + for (String vendor : VENDORS) { + List versions = versionsOf(vendor); + assertEquals(versions.size(), versions.stream().distinct().count(), + () -> vendor + " declares a version more than once: " + versions); + byVendor.put(vendor, versions); + } + + List reference = byVendor.get(VENDORS.get(0)); + for (Map.Entry> 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 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 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 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); + } + } +} diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MysqlMigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MysqlMigrationUpgradeTest.java new file mode 100644 index 0000000000..793fb08ca6 --- /dev/null +++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MysqlMigrationUpgradeTest.java @@ -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 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 storedRoutines() throws SQLException { + return queryStrings("SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()"); + } +} diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/PostgresqlMigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/PostgresqlMigrationUpgradeTest.java new file mode 100644 index 0000000000..1430a9ae77 --- /dev/null +++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/PostgresqlMigrationUpgradeTest.java @@ -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 expectedBooleanTypes() { + return Set.of("bool"); + } + + @Override + protected String expectedEnlargedTextType() { + return "text"; + } + + @Override + protected List storedRoutines() throws SQLException { + return queryStrings("SELECT routine_name FROM information_schema.routines " + + "WHERE routine_schema = current_schema()"); + } +}