From 870593bae88763b5173c22ee864d67a16e2a6d8e Mon Sep 17 00:00:00 2001 From: Athou Date: Sun, 4 Feb 2024 08:38:11 +0100 Subject: [PATCH] add H2 migration tool --- commafeed-server/pom.xml | 8 +- .../com/commafeed/CommaFeedApplication.java | 30 ++++++- .../{ => db}/DatabaseCleaningService.java | 2 +- .../{ => db}/DatabaseStartupService.java | 3 +- .../service/db/H2MigrationService.java | 76 ++++++++++++++++++ ...triesExceedingFeedCapacityCleanupTask.java | 2 +- .../backend/task/OldEntriesCleanupTask.java | 2 +- .../backend/task/OldStatusesCleanupTask.java | 2 +- .../task/OrphanedContentsCleanupTask.java | 2 +- .../task/OrphanedFeedsCleanupTask.java | 2 +- .../service/db/H2MigrationServiceTest.java | 29 +++++++ .../h2-migration/database-v2.1.214.mv.db | Bin 0 -> 28672 bytes 12 files changed, 148 insertions(+), 10 deletions(-) rename commafeed-server/src/main/java/com/commafeed/backend/service/{ => db}/DatabaseCleaningService.java (96%) rename commafeed-server/src/main/java/com/commafeed/backend/service/{ => db}/DatabaseStartupService.java (94%) create mode 100644 commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java create mode 100644 commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java create mode 100644 commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db diff --git a/commafeed-server/pom.xml b/commafeed-server/pom.xml index 6a75467d..07b40915 100644 --- a/commafeed-server/pom.xml +++ b/commafeed-server/pom.xml @@ -405,9 +405,13 @@ com.h2database h2 - - 2.1.214 + + com.manticore-projects.tools + h2migrationtool + 1.4 + + com.mysql mysql-connector-j diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java index 7f8e79a3..bee52d18 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java @@ -1,6 +1,8 @@ package com.commafeed; import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Instant; import java.util.EnumSet; import java.util.Set; @@ -24,8 +26,9 @@ import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.User; import com.commafeed.backend.model.UserRole; import com.commafeed.backend.model.UserSettings; -import com.commafeed.backend.service.DatabaseStartupService; import com.commafeed.backend.service.UserService; +import com.commafeed.backend.service.db.DatabaseStartupService; +import com.commafeed.backend.service.db.H2MigrationService; import com.commafeed.backend.task.ScheduledTask; import com.commafeed.frontend.auth.PasswordConstraintValidator; import com.commafeed.frontend.auth.SecurityCheckFactoryProvider; @@ -58,6 +61,7 @@ import io.dropwizard.configuration.DefaultConfigurationFactoryFactory; import io.dropwizard.configuration.EnvironmentVariableSubstitutor; import io.dropwizard.configuration.SubstitutingSourceProvider; import io.dropwizard.core.Application; +import io.dropwizard.core.ConfiguredBundle; import io.dropwizard.core.setup.Bootstrap; import io.dropwizard.core.setup.Environment; import io.dropwizard.db.DataSourceFactory; @@ -93,6 +97,30 @@ public class CommaFeedApplication extends Application { configureEnvironmentSubstitutor(bootstrap); configureObjectMapper(bootstrap.getObjectMapper()); + // run h2 migration as the first bundle because we need to migrate before hibernate is initialized + bootstrap.addBundle(new ConfiguredBundle() { + @Override + public void run(CommaFeedConfiguration config, Environment environment) throws Exception { + DataSourceFactory dataSourceFactory = config.getDataSourceFactory(); + String url = dataSourceFactory.getUrl(); + if (isFileBasedH2(url)) { + Path path = getFilePath(url); + String user = dataSourceFactory.getUser(); + String password = dataSourceFactory.getPassword(); + new H2MigrationService().migrateIfNeeded(path, user, password); + } + } + + private boolean isFileBasedH2(String url) { + return url.startsWith("jdbc:h2:") && !url.startsWith("jdbc:h2:mem:"); + } + + private Path getFilePath(String url) { + String name = url.substring("jdbc:h2:".length()).split(";")[0]; + return Paths.get(name + ".mv.db"); + } + }); + bootstrap.addBundle(hibernateBundle = new HibernateBundle<>(AbstractModel.class, Feed.class, FeedCategory.class, FeedEntry.class, FeedEntryContent.class, FeedEntryStatus.class, FeedEntryTag.class, FeedSubscription.class, User.class, UserRole.class, UserSettings.class) { diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseCleaningService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java similarity index 96% rename from commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseCleaningService.java rename to commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java index 336a05cf..a8f54d4e 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseCleaningService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java @@ -1,4 +1,4 @@ -package com.commafeed.backend.service; +package com.commafeed.backend.service.db; import java.time.Instant; import java.util.List; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseStartupService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java similarity index 94% rename from commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseStartupService.java rename to commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java index 6675b67a..865118e6 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseStartupService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java @@ -1,4 +1,4 @@ -package com.commafeed.backend.service; +package com.commafeed.backend.service.db; import java.util.HashMap; import java.util.Map; @@ -9,6 +9,7 @@ import org.hibernate.SessionFactory; import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.service.UserService; import io.dropwizard.lifecycle.Managed; import jakarta.inject.Inject; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java new file mode 100644 index 00000000..39e9e9a4 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java @@ -0,0 +1,76 @@ +package com.commafeed.backend.service.db; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; + +import com.manticore.h2.H2MigrationTool; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class H2MigrationService { + + public void migrateIfNeeded(Path path, String user, String password) { + int format; + try { + format = getH2FileFormat(path); + } catch (IOException e) { + throw new RuntimeException("could not detect H2 format", e); + } + + if (format == 2) { + try { + migrate(path, user, password, "2.1.214", "2.2.224"); + } catch (Exception e) { + throw new RuntimeException("could not migrate H2 to format 3", e); + } + } + } + + public int getH2FileFormat(Path path) throws IOException { + try (BufferedReader reader = Files.newBufferedReader(path)) { + String headers = reader.readLine(); + + return Stream.of(headers.split(",")) + .filter(h -> h.startsWith("format:")) + .map(h -> h.split(":")[1]) + .map(Integer::parseInt) + .findFirst() + .orElseThrow(() -> new RuntimeException("could not find format in H2 file headers")); + } + } + + private void migrate(Path path, String user, String password, String fromVersion, String toVersion) throws Exception { + log.info("migrating H2 database at {} from format {} to format {}", path, fromVersion, toVersion); + + Path scriptPath = path.resolveSibling("script-" + System.currentTimeMillis() + ".sql"); + Path newVersionPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(toVersion) + ".mv.db"); + Path oldVersionBackupPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(fromVersion) + ".backup"); + + H2MigrationTool.readDriverRecords(); + new H2MigrationTool().migrate(fromVersion, toVersion, path.toAbsolutePath().toString(), user, password, + scriptPath.toAbsolutePath().toString(), "", "", false, false, ""); + if (!Files.exists(newVersionPath)) { + throw new RuntimeException("H2 migration failed, new version file not found"); + } + + Files.move(path, oldVersionBackupPath); + Files.move(newVersionPath, path); + Files.delete(oldVersionBackupPath); + Files.delete(scriptPath); + + log.info("migrated H2 database from format {} to format {}", fromVersion, toVersion); + } + + private String getPatchVersion(String version) { + return StringUtils.substringAfterLast(version, "."); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java index 98b82191..5508cbd7 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java @@ -3,7 +3,7 @@ package com.commafeed.backend.task; import java.util.concurrent.TimeUnit; import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.service.DatabaseCleaningService; +import com.commafeed.backend.service.db.DatabaseCleaningService; import jakarta.inject.Inject; import jakarta.inject.Singleton; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java index 76328ef3..fad42a75 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java @@ -5,7 +5,7 @@ import java.time.Instant; import java.util.concurrent.TimeUnit; import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.service.DatabaseCleaningService; +import com.commafeed.backend.service.db.DatabaseCleaningService; import jakarta.inject.Inject; import jakarta.inject.Singleton; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java index 82c12de1..0c19afe3 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java @@ -4,7 +4,7 @@ import java.time.Instant; import java.util.concurrent.TimeUnit; import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.service.DatabaseCleaningService; +import com.commafeed.backend.service.db.DatabaseCleaningService; import jakarta.inject.Inject; import jakarta.inject.Singleton; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java index aa5731f9..7b2afcaa 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java @@ -2,7 +2,7 @@ package com.commafeed.backend.task; import java.util.concurrent.TimeUnit; -import com.commafeed.backend.service.DatabaseCleaningService; +import com.commafeed.backend.service.db.DatabaseCleaningService; import jakarta.inject.Inject; import jakarta.inject.Singleton; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java index 4405e49a..4606dad3 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java @@ -2,7 +2,7 @@ package com.commafeed.backend.task; import java.util.concurrent.TimeUnit; -import com.commafeed.backend.service.DatabaseCleaningService; +import com.commafeed.backend.service.db.DatabaseCleaningService; import jakarta.inject.Inject; import jakarta.inject.Singleton; diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java new file mode 100644 index 00000000..578cbc3e --- /dev/null +++ b/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java @@ -0,0 +1,29 @@ +package com.commafeed.backend.service.db; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class H2MigrationServiceTest { + + @TempDir + private Path root; + + @Test + void testMigrateIfNeeded() throws IOException { + Path path = root.resolve("database.mv.db"); + Files.copy(Objects.requireNonNull(getClass().getResourceAsStream("/h2-migration/database-v2.1.214.mv.db")), path); + + H2MigrationService service = new H2MigrationService(); + Assertions.assertEquals(2, service.getH2FileFormat(path)); + + service.migrateIfNeeded(path, "sa", "sa"); + Assertions.assertEquals(3, service.getH2FileFormat(path)); + } + +} \ No newline at end of file diff --git a/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db b/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..b11490bc25ae368a0b1e2fa5130b571280159217 GIT binary patch literal 28672 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 a3w(F|y?^n4^kgU=f~r7~`M>a&`TqeXk;v}= literal 0 HcmV?d00001