001/*-
002 * #%L
003 * HAPI FHIR Server - SQL Migration
004 * %%
005 * Copyright (C) 2014 - 2023 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.migrate;
021
022import ca.uhn.fhir.i18n.Msg;
023import ca.uhn.fhir.jpa.migrate.dao.HapiMigrationDao;
024import ca.uhn.fhir.jpa.migrate.taskdef.BaseTask;
025import ca.uhn.fhir.jpa.migrate.taskdef.InitializeSchemaTask;
026import ca.uhn.fhir.system.HapiSystemProperties;
027import ca.uhn.fhir.util.StopWatch;
028import com.google.common.annotations.VisibleForTesting;
029import org.apache.commons.lang3.Validate;
030import org.slf4j.Logger;
031import org.slf4j.LoggerFactory;
032
033import javax.annotation.Nonnull;
034import javax.sql.DataSource;
035import java.sql.SQLException;
036import java.util.Collections;
037import java.util.List;
038import java.util.Objects;
039
040import static org.apache.commons.lang3.StringUtils.isBlank;
041
042public class HapiMigrator {
043
044        private static final Logger ourLog = LoggerFactory.getLogger(HapiMigrator.class);
045        private final MigrationTaskList myTaskList = new MigrationTaskList();
046        private boolean myDryRun;
047        private boolean myNoColumnShrink;
048        private final DriverTypeEnum myDriverType;
049        private final DataSource myDataSource;
050        private final HapiMigrationStorageSvc myHapiMigrationStorageSvc;
051        private List<IHapiMigrationCallback> myCallbacks = Collections.emptyList();
052
053        public HapiMigrator(String theMigrationTableName, DataSource theDataSource, DriverTypeEnum theDriverType) {
054                myDriverType = theDriverType;
055                myDataSource = theDataSource;
056                myHapiMigrationStorageSvc = new HapiMigrationStorageSvc(new HapiMigrationDao(theDataSource, theDriverType, theMigrationTableName));
057        }
058
059        public DataSource getDataSource() {
060                return myDataSource;
061        }
062
063        public boolean isDryRun() {
064                return myDryRun;
065        }
066
067        public void setDryRun(boolean theDryRun) {
068                myDryRun = theDryRun;
069        }
070
071        public boolean isNoColumnShrink() {
072                return myNoColumnShrink;
073        }
074
075        public void setNoColumnShrink(boolean theNoColumnShrink) {
076                myNoColumnShrink = theNoColumnShrink;
077        }
078
079        public DriverTypeEnum getDriverType() {
080                return myDriverType;
081        }
082
083
084        protected StringBuilder buildExecutedStatementsString(MigrationResult theMigrationResult) {
085                StringBuilder statementBuilder = new StringBuilder();
086                String lastTable = null;
087                for (BaseTask.ExecutedStatement next : theMigrationResult.executedStatements) {
088                        if (!Objects.equals(lastTable, next.getTableName())) {
089                                statementBuilder.append("\n\n-- Table: ").append(next.getTableName()).append("\n");
090                                lastTable = next.getTableName();
091                        }
092
093                        statementBuilder.append(next.getSql()).append(";\n");
094
095                        for (Object nextArg : next.getArguments()) {
096                                statementBuilder.append("  -- Arg: ").append(nextArg).append("\n");
097                        }
098                }
099                return statementBuilder;
100        }
101
102        /**
103         * Helper method to clear a lock with a given UUID.
104         * @param theUUID the
105         */
106        public void clearMigrationLockWithUUID(String theUUID) {
107                ourLog.info("Attempting to remove lock entry. [uuid={}]", theUUID);
108                boolean success = myHapiMigrationStorageSvc.deleteLockRecord(theUUID);
109                if (success) {
110                        ourLog.info("Successfully removed lock entry. [uuid={}]", theUUID);
111                } else {
112                        ourLog.error("Did not successfully remove lock entry. [uuid={}]", theUUID);
113                }
114        }
115
116        public MigrationResult migrate() {
117                ourLog.info("Loaded {} migration tasks", myTaskList.size());
118                MigrationResult retval = new MigrationResult();
119
120                // Lock the migration table so only one server migrates the database at once
121                try (HapiMigrationLock ignored = new HapiMigrationLock(myHapiMigrationStorageSvc)) {
122                        MigrationTaskList newTaskList = myHapiMigrationStorageSvc.diff(myTaskList);
123                        ourLog.info("{} of these {} migration tasks are new.  Executing them now.", newTaskList.size(), myTaskList.size());
124
125                        try (DriverTypeEnum.ConnectionProperties connectionProperties = getDriverType().newConnectionProperties(getDataSource())) {
126
127                                newTaskList.forEach(next -> {
128
129                                        next.setDriverType(getDriverType());
130                                        next.setDryRun(isDryRun());
131                                        next.setNoColumnShrink(isNoColumnShrink());
132                                        next.setConnectionProperties(connectionProperties);
133
134                                        executeTask(next, retval);
135                                });
136                        }
137                } catch (Exception e) {
138                        ourLog.error("Migration failed", e);
139                        throw e;
140                }
141
142                ourLog.info(retval.summary());
143
144                if (isDryRun()) {
145                        StringBuilder statementBuilder = buildExecutedStatementsString(retval);
146                        ourLog.info("SQL that would be executed:\n\n***********************************\n{}***********************************", statementBuilder);
147                }
148
149                return retval;
150        }
151
152        private void executeTask(BaseTask theTask, MigrationResult theMigrationResult) {
153                StopWatch sw = new StopWatch();
154                try {
155                        if (isDryRun()) {
156                                ourLog.info("Dry run {} {}", theTask.getMigrationVersion(), theTask.getDescription());
157                        } else {
158                                ourLog.info("Executing {} {}", theTask.getMigrationVersion(), theTask.getDescription());
159                        }
160                        preExecute(theTask);
161                        theTask.execute();
162                        postExecute(theTask, sw, true);
163                        theMigrationResult.changes += theTask.getChangesCount();
164                        theMigrationResult.executedStatements.addAll(theTask.getExecutedStatements());
165                        theMigrationResult.succeededTasks.add(theTask);
166                } catch (SQLException | HapiMigrationException e) {
167                        theMigrationResult.failedTasks.add(theTask);
168                        postExecute(theTask, sw, false);
169                        String description = theTask.getDescription();
170                        if (isBlank(description)) {
171                                description = theTask.getClass().getSimpleName();
172                        }
173                        String prefix = "Failure executing task \"" + description + "\", aborting! Cause: ";
174                        throw new HapiMigrationException(Msg.code(47) + prefix + e, theMigrationResult, e);
175                }
176        }
177
178        private void preExecute(BaseTask theTask) {
179                myCallbacks.forEach(action -> action.preExecution(theTask));
180
181        }
182
183        private void postExecute(BaseTask theNext, StopWatch theStopWatch, boolean theSuccess) {
184                myHapiMigrationStorageSvc.saveTask(theNext, Math.toIntExact(theStopWatch.getMillis()), theSuccess);
185        }
186
187        public void addTasks(Iterable<BaseTask> theMigrationTasks) {
188                if (HapiSystemProperties.isUnitTestModeEnabled()) {
189                        // Tests only need to initialize the schemas. No need to run all the migrations for every test.
190                        for (BaseTask task : theMigrationTasks) {
191                                if (task instanceof InitializeSchemaTask) {
192                                        addTask(task);
193                                }
194                        }
195                } else {
196                        myTaskList.append(theMigrationTasks);
197                }
198        }
199
200        public void addTask(BaseTask theTask) {
201                myTaskList.add(theTask);
202        }
203
204        public void setCallbacks(@Nonnull List<IHapiMigrationCallback> theCallbacks) {
205                Validate.notNull(theCallbacks);
206                myCallbacks = theCallbacks;
207        }
208
209        @VisibleForTesting
210        public void removeAllTasksForUnitTest() {
211                myTaskList.clear();
212        }
213
214        public void createMigrationTableIfRequired() {
215                myHapiMigrationStorageSvc.createMigrationTableIfRequired();
216        }
217}