001/*-
002 * #%L
003 * HAPI FHIR Server - SQL Migration
004 * %%
005 * Copyright (C) 2014 - 2024 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.jpa.migrate.tasks.api.TaskFlagEnum;
027import ca.uhn.fhir.system.HapiSystemProperties;
028import ca.uhn.fhir.util.StopWatch;
029import com.google.common.annotations.VisibleForTesting;
030import jakarta.annotation.Nonnull;
031import org.apache.commons.lang3.Validate;
032import org.slf4j.Logger;
033import org.slf4j.LoggerFactory;
034
035import java.sql.SQLException;
036import java.util.Collections;
037import java.util.List;
038import java.util.Objects;
039import javax.sql.DataSource;
040
041import static org.apache.commons.lang3.StringUtils.isBlank;
042
043public class HapiMigrator {
044
045        private static final Logger ourLog = LoggerFactory.getLogger(HapiMigrator.class);
046        private final MigrationTaskList myTaskList = new MigrationTaskList();
047        private boolean myDryRun;
048        private boolean myRunHeavyweightSkippableTasks;
049        private boolean myNoColumnShrink;
050        private final DriverTypeEnum myDriverType;
051        private final DataSource myDataSource;
052        private final HapiMigrationStorageSvc myHapiMigrationStorageSvc;
053        private List<IHapiMigrationCallback> myCallbacks = Collections.emptyList();
054
055        public HapiMigrator(String theMigrationTableName, DataSource theDataSource, DriverTypeEnum theDriverType) {
056                myDriverType = theDriverType;
057                myDataSource = theDataSource;
058                myHapiMigrationStorageSvc =
059                                new HapiMigrationStorageSvc(new HapiMigrationDao(theDataSource, theDriverType, theMigrationTableName));
060        }
061
062        public DataSource getDataSource() {
063                return myDataSource;
064        }
065
066        public boolean isDryRun() {
067                return myDryRun;
068        }
069
070        public void setDryRun(boolean theDryRun) {
071                myDryRun = theDryRun;
072        }
073
074        /**
075         * Should we run the tasks marked with {@link ca.uhn.fhir.jpa.migrate.tasks.api.TaskFlagEnum#HEAVYWEIGHT_SKIP_BY_DEFAULT}
076         *
077         * @since 7.4.0
078         */
079        public boolean isRunHeavyweightSkippableTasks() {
080                return myRunHeavyweightSkippableTasks;
081        }
082
083        /**
084         * Should we run the tasks marked with {@link ca.uhn.fhir.jpa.migrate.tasks.api.TaskFlagEnum#HEAVYWEIGHT_SKIP_BY_DEFAULT}
085         *
086         * @since 7.4.0
087         */
088        public void setRunHeavyweightSkippableTasks(boolean theRunHeavyweightSkippableTasks) {
089                myRunHeavyweightSkippableTasks = theRunHeavyweightSkippableTasks;
090        }
091
092        public boolean isNoColumnShrink() {
093                return myNoColumnShrink;
094        }
095
096        public void setNoColumnShrink(boolean theNoColumnShrink) {
097                myNoColumnShrink = theNoColumnShrink;
098        }
099
100        public DriverTypeEnum getDriverType() {
101                return myDriverType;
102        }
103
104        protected StringBuilder buildExecutedStatementsString(MigrationResult theMigrationResult) {
105                StringBuilder statementBuilder = new StringBuilder();
106                String lastTable = null;
107                for (BaseTask.ExecutedStatement next : theMigrationResult.executedStatements) {
108                        if (!Objects.equals(lastTable, next.getTableName())) {
109                                statementBuilder
110                                                .append("\n\n-- Table: ")
111                                                .append(next.getTableName())
112                                                .append("\n");
113                                lastTable = next.getTableName();
114                        }
115
116                        statementBuilder.append(next.getSql()).append(";\n");
117
118                        for (Object nextArg : next.getArguments()) {
119                                statementBuilder.append("  -- Arg: ").append(nextArg).append("\n");
120                        }
121                }
122                return statementBuilder;
123        }
124
125        /**
126         * Helper method to clear a lock with a given UUID.
127         * @param theUUID the
128         */
129        public void clearMigrationLockWithUUID(String theUUID) {
130                ourLog.info("Attempting to remove lock entry. [uuid={}]", theUUID);
131                boolean success = myHapiMigrationStorageSvc.deleteLockRecord(theUUID);
132                if (success) {
133                        ourLog.info("Successfully removed lock entry. [uuid={}]", theUUID);
134                } else {
135                        ourLog.error("Did not successfully remove lock entry. [uuid={}]", theUUID);
136                }
137        }
138
139        public MigrationResult migrate() {
140                ourLog.info("Loaded {} migration tasks", myTaskList.size());
141                MigrationResult retval = new MigrationResult();
142
143                // Lock the migration table so only one server migrates the database at once
144                try (HapiMigrationLock ignored = new HapiMigrationLock(myHapiMigrationStorageSvc)) {
145                        MigrationTaskList newTaskList = myHapiMigrationStorageSvc.diff(myTaskList);
146                        ourLog.info(
147                                        "{} of these {} migration tasks are new.  Executing them now.",
148                                        newTaskList.size(),
149                                        myTaskList.size());
150
151                        try (DriverTypeEnum.ConnectionProperties connectionProperties =
152                                        getDriverType().newConnectionProperties(getDataSource())) {
153
154                                if (!isRunHeavyweightSkippableTasks()) {
155                                        newTaskList.removeIf(BaseTask::isHeavyweightSkippableTask);
156                                }
157
158                                boolean initializedSchema = false;
159                                for (BaseTask next : newTaskList) {
160                                        if (initializedSchema && !next.hasFlag(TaskFlagEnum.RUN_DURING_SCHEMA_INITIALIZATION)) {
161                                                ourLog.info("Skipping task {} because schema is being initialized", next.getMigrationVersion());
162                                                recordTaskAsCompletedIfNotDryRun(next, 0L, true);
163                                                continue;
164                                        }
165
166                                        next.setDriverType(getDriverType());
167                                        next.setDryRun(isDryRun());
168                                        next.setNoColumnShrink(isNoColumnShrink());
169                                        next.setConnectionProperties(connectionProperties);
170
171                                        executeTask(next, retval);
172
173                                        initializedSchema |= next.initializedSchema();
174                                }
175                        }
176                } catch (Exception e) {
177                        ourLog.error("Migration failed", e);
178                        throw e;
179                }
180
181                ourLog.info(retval.summary());
182
183                if (isDryRun()) {
184                        StringBuilder statementBuilder = buildExecutedStatementsString(retval);
185                        ourLog.info(
186                                        "SQL that would be executed:\n\n***********************************\n{}***********************************",
187                                        statementBuilder);
188                }
189
190                return retval;
191        }
192
193        private void executeTask(BaseTask theTask, MigrationResult theMigrationResult) {
194                StopWatch sw = new StopWatch();
195                try {
196                        if (isDryRun()) {
197                                ourLog.info("Dry run {} {}", theTask.getMigrationVersion(), theTask.getDescription());
198                        } else {
199                                ourLog.info("Executing {} {}", theTask.getMigrationVersion(), theTask.getDescription());
200                        }
201                        preExecute(theTask);
202                        theTask.execute();
203                        recordTaskAsCompletedIfNotDryRun(theTask, sw.getMillis(), true);
204                        theMigrationResult.changes += theTask.getChangesCount();
205                        theMigrationResult.executedStatements.addAll(theTask.getExecutedStatements());
206                        theMigrationResult.succeededTasks.add(theTask);
207                } catch (SQLException | HapiMigrationException e) {
208                        theMigrationResult.failedTasks.add(theTask);
209                        recordTaskAsCompletedIfNotDryRun(theTask, sw.getMillis(), false);
210                        String description = theTask.getDescription();
211                        if (isBlank(description)) {
212                                description = theTask.getClass().getSimpleName();
213                        }
214                        String prefix = String.format(
215                                        "Failure executing task '%s', for driver: %s, aborting! Cause: ", description, getDriverType());
216                        throw new HapiMigrationException(Msg.code(47) + prefix + e, theMigrationResult, e);
217                }
218        }
219
220        private void preExecute(BaseTask theTask) {
221                myCallbacks.forEach(action -> action.preExecution(theTask));
222        }
223
224        private void recordTaskAsCompletedIfNotDryRun(BaseTask theNext, long theExecutionMillis, boolean theSuccess) {
225                if (!theNext.isDryRun()) {
226                        myHapiMigrationStorageSvc.saveTask(theNext, Math.toIntExact(theExecutionMillis), theSuccess);
227                }
228        }
229
230        public void addTasks(Iterable<BaseTask> theMigrationTasks) {
231                if (HapiSystemProperties.isUnitTestModeEnabled()) {
232                        // Tests only need to initialize the schemas. No need to run all the migrations for every test.
233                        for (BaseTask task : theMigrationTasks) {
234                                if (task instanceof InitializeSchemaTask) {
235                                        addTask(task);
236                                }
237                        }
238                } else {
239                        myTaskList.append(theMigrationTasks);
240                }
241        }
242
243        public void addTask(BaseTask theTask) {
244                myTaskList.add(theTask);
245        }
246
247        public void setCallbacks(@Nonnull List<IHapiMigrationCallback> theCallbacks) {
248                Validate.notNull(theCallbacks, "theCallbacks must not be null");
249                myCallbacks = theCallbacks;
250        }
251
252        @VisibleForTesting
253        public void removeAllTasksForUnitTest() {
254                myTaskList.clear();
255        }
256
257        public void createMigrationTableIfRequired() {
258                if (!myDryRun) {
259                        myHapiMigrationStorageSvc.createMigrationTableIfRequired();
260                }
261        }
262}