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