From a1485e662bca8bfdbcda7f3af6652beb64607c93 Mon Sep 17 00:00:00 2001 From: Tobias Koch Date: Mon, 22 Aug 2022 09:55:13 +0200 Subject: [PATCH] Add migration endpoint to migrate data (#99) --- .../DatabaseMigrationController.java | 45 +++++++++++ .../groovy/life/qbic/db/tools/Migrator.groovy | 80 +++++++++++++++++++ .../rest/v2/samples/SamplesControllerV2.java | 2 +- .../java/life/qbic/domain/sample/Sample.java | 27 +++++-- 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 src/main/groovy/life/qbic/controller/DatabaseMigrationController.java create mode 100644 src/main/groovy/life/qbic/db/tools/Migrator.groovy diff --git a/src/main/groovy/life/qbic/controller/DatabaseMigrationController.java b/src/main/groovy/life/qbic/controller/DatabaseMigrationController.java new file mode 100644 index 0000000..c630931 --- /dev/null +++ b/src/main/groovy/life/qbic/controller/DatabaseMigrationController.java @@ -0,0 +1,45 @@ +package life.qbic.controller; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; +import java.time.Instant; +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import life.qbic.auth.Authentication; +import life.qbic.db.tools.Migrator; + +/** + * Utility tool to migrate data. + * + * @since 2.0.0 + */ +@Requires(beans = Authentication.class) +@Secured(SecurityRule.IS_AUTHENTICATED) +@Controller("/v2/migrate") +public class DatabaseMigrationController { + + private final Migrator migrator; + + @Inject + public DatabaseMigrationController(Migrator migrator) { + this.migrator = migrator; + } + + @Get(produces = MediaType.APPLICATION_JSON) + @RolesAllowed("WRITER") + void migrateAllData() { + migrator.migrateFromVersionOneToVersionTwo(); + } + + @Get(uri = "/{utcDateTime}", produces = MediaType.APPLICATION_JSON) + @RolesAllowed("WRITER") + void migrateAllData(@PathVariable String utcDateTime) { + Instant earliestTime = Instant.parse(utcDateTime); + migrator.migrateFromVersionOneToVersionTwo(earliestTime); + } +} diff --git a/src/main/groovy/life/qbic/db/tools/Migrator.groovy b/src/main/groovy/life/qbic/db/tools/Migrator.groovy new file mode 100644 index 0000000..52ef48b --- /dev/null +++ b/src/main/groovy/life/qbic/db/tools/Migrator.groovy @@ -0,0 +1,80 @@ +package life.qbic.db.tools + +import groovy.sql.Sql +import life.qbic.api.rest.v2.samples.SampleStatusDto +import life.qbic.api.rest.v2.samples.SamplesControllerV2 +import life.qbic.api.rest.v2.samples.StatusChangeRequest + +import javax.inject.Inject +import javax.inject.Singleton +import javax.sql.DataSource +import java.sql.Timestamp +import java.time.Instant + +import static java.util.Objects.requireNonNull + +@Singleton +class Migrator { + + private final DbInteractor dbInteractor + private final SamplesControllerV2 controllerV2 + + @Inject + Migrator(DbInteractor dbInteractor, SamplesControllerV2 controllerV2) { + this.dbInteractor = dbInteractor + this.controllerV2 = controllerV2 + } + + /** + * migrates data from the old data schema to the schema of v2. Starts at the earliestModification. + * @param earliestModification + */ + void migrateFromVersionOneToVersionTwo(Instant earliestModification) { + dbInteractor.statusChangeRequests(earliestModification).forEach(it -> controllerV2.moveSampleToStatus(it.sampleCode, new StatusChangeRequest(it.statusDto, it.arrivalTime.toString()))) + } + + /** + * migrates data from the old data schema to the schema of v2. + */ + void migrateFromVersionOneToVersionTwo() { + dbInteractor.statusChangeRequests(new Date(0).toInstant()).forEach(it -> controllerV2.moveSampleToStatus(it.sampleCode, new StatusChangeRequest(it.statusDto, it.arrivalTime.toString()))) + } + + static class StatusChange { + final String sampleCode + final SampleStatusDto statusDto + final Instant arrivalTime + + StatusChange(String sampleCode, SampleStatusDto statusDto, Instant arrivalTime) { + this.sampleCode = sampleCode + this.statusDto = statusDto + this.arrivalTime = arrivalTime + } + } + + @Singleton + static class DbInteractor { + + private final DataSource dataSource + + @Inject + DbInteractor(DataSource dataSource) { + this.dataSource = dataSource + } + + List statusChangeRequests(Instant earliestModification) { + def connection = requireNonNull(dataSource.getConnection()) + String query = "SELECT sample_id, sample_status, arrival_time FROM samples_locations WHERE arrival_time >= :earliestModification ORDER BY arrival_time" + try (Sql sql = new Sql(connection)) { + def rows = sql.rows(query, [earliestModification: Timestamp.from(earliestModification)]) + return rows.stream() + .map(it -> new StatusChange( + it.get("sample_id") as String, + SampleStatusDto.valueOf(it.get("sample_status") as String), + (it.get("arrival_time") as Timestamp).toInstant())) + .sorted((it1, it2) -> it1.arrivalTime.compareTo(it2.arrivalTime)) + .collect() + } + } + } +} diff --git a/src/main/java/life/qbic/api/rest/v2/samples/SamplesControllerV2.java b/src/main/java/life/qbic/api/rest/v2/samples/SamplesControllerV2.java index a277bdb..a19b1a8 100644 --- a/src/main/java/life/qbic/api/rest/v2/samples/SamplesControllerV2.java +++ b/src/main/java/life/qbic/api/rest/v2/samples/SamplesControllerV2.java @@ -72,7 +72,7 @@ public HttpResponse moveSampleToStatus(@PathVariable String sampleCode, "Provided sample status not recognized: " + requestedStatus, ErrorCode.BAD_SAMPLE_STATUS, ErrorParameters.create().with("sampleStatus", requestedStatus)); } - log.info(String.format("Sample %s is in status %s valid since %s", sampleCode, statusChangeRequest.status(), statusChangeRequest.validSince())); + log.info(String.format("Processed request for sample %s to status %s at %s.", sampleCode, statusChangeRequest.status(), statusChangeRequest.validSince())); return HttpResponse.ok(); } diff --git a/src/main/java/life/qbic/domain/sample/Sample.java b/src/main/java/life/qbic/domain/sample/Sample.java index 787045e..aca048c 100644 --- a/src/main/java/life/qbic/domain/sample/Sample.java +++ b/src/main/java/life/qbic/domain/sample/Sample.java @@ -50,7 +50,9 @@ public static Sample fromEvents(Collection events) { throw new IllegalArgumentException( "Sample creation from events not possible without provided events."); } - Optional containedSampleCode = events.stream().findAny().map(SampleEvent::sampleCode); + Optional containedSampleCode = events.stream() + .findAny() + .map(SampleEvent::sampleCode); SampleCode sampleCode = containedSampleCode.orElseThrow(() -> new UnrecoverableException("Could not identify sample code from events: " + events)); if (events.stream().anyMatch(it -> !it.sampleCode().equals(sampleCode))) { @@ -104,20 +106,26 @@ public void handle(T event) { if (events.contains(event)) { return; } - if (!occurredAfterCurrentState(event)) { - throw new UnrecoverableException( - String.format("The sample (%s) was modified after %s", sampleCode, event.occurredOn())); - } + validateEventTimeOrThrowException(event); apply(event); events.add(event); } - private boolean occurredAfterCurrentState(SampleEvent event) { + private void validateEventTimeOrThrowException(SampleEvent event) { if (events.isEmpty()) { - return true; + return; } SampleEvent lastEvent = events.get(events.size() - 1); - return event.occurredOn().isAfter(lastEvent.occurredOn()); + if (!event.occurredOn().isAfter(lastEvent.occurredOn())) { + throw new UnrecoverableException( + String.format( + "The sample (%s) was last modified at %s by %s. Modification with %s event at %s not possible.", + sampleCode, + lastEvent.occurredOn(), + lastEvent.getClass().getSimpleName(), + event.getClass().getSimpleName(), + event.occurredOn())); + } } public void apply(T event) { @@ -140,6 +148,7 @@ public void apply(T event) { /** * A history of events leading to the current state. + * * @return all events leading to the current state of the sample * @since 2.0.0 */ @@ -187,6 +196,7 @@ public static class CurrentState { /** * The status the sample is in. + * * @return the current status */ public Status status() { @@ -195,6 +205,7 @@ public Status status() { /** * The instant from which the current state is valid from. + * * @return the instant of this state */ public Instant statusValidSince() {