001/*-
002 * #%L
003 * HAPI FHIR Server - SQL Migration
004 * %%
005 * Copyright (C) 2014 - 2026 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                                int skippedTasksDueToSchemaMigration = 0;
167                                for (BaseTask next : newTaskList) {
168                                        if (initializedSchema && !next.hasFlag(TaskFlagEnum.RUN_DURING_SCHEMA_INITIALIZATION)) {
169                                                ourLog.debug(
170                                                                "Skipping task {} because schema is being initialized", next.getMigrationVersion());
171                                                recordTaskAsCompletedIfNotDryRun(next, 0L, true);
172                                                skippedTasksDueToSchemaMigration++;
173                                                continue;
174                                        }
175
176                                        next.setDriverType(getDriverType());
177                                        next.setDryRun(isDryRun());
178                                        next.setNoColumnShrink(isNoColumnShrink());
179                                        next.setConnectionProperties(connectionProperties);
180
181                                        executeTask(next, retval);
182
183                                        initializedSchema |= next.initializedSchema();
184                                }
185
186                                if (skippedTasksDueToSchemaMigration > 0) {
187                                        ourLog.info(
188                                                        "Skipped {} migration tasks because schema is being initialized",
189                                                        skippedTasksDueToSchemaMigration);
190                                }
191                        }
192                } catch (Exception e) {
193                        ourLog.error("Migration failed", e);
194                        throw e;
195                }
196
197                ourLog.info(retval.summary());
198
199                if (isDryRun()) {
200                        StringBuilder statementBuilder = buildExecutedStatementsString(retval);
201                        ourLog.info(
202                                        "SQL that would be executed:\n\n***********************************\n{}***********************************",
203                                        statementBuilder);
204                }
205
206                return retval;
207        }
208
209        private void executeTask(BaseTask theTask, MigrationResult theMigrationResult) {
210                StopWatch sw = new StopWatch();
211                try {
212                        if (isDryRun()) {
213                                ourLog.info("Dry run {} {}", theTask.getMigrationVersion(), theTask.getDescription());
214                        } else {
215                                ourLog.info("Executing {} {}", theTask.getMigrationVersion(), theTask.getDescription());
216                        }
217                        preExecute(theTask);
218                        theTask.execute();
219                        recordTaskAsCompletedIfNotDryRun(theTask, sw.getMillis(), true);
220                        theMigrationResult.changes += theTask.getChangesCount();
221                        theMigrationResult.executionResult = theTask.getExecutionResult();
222                        theMigrationResult.executedStatements.addAll(theTask.getExecutedStatements());
223                        theMigrationResult.succeededTasks.add(theTask);
224                } catch (SQLException | HapiMigrationException e) {
225                        theMigrationResult.failedTasks.add(theTask);
226                        recordTaskAsCompletedIfNotDryRun(theTask, sw.getMillis(), false);
227                        String description = theTask.getDescription();
228                        if (isBlank(description)) {
229                                description = theTask.getClass().getSimpleName();
230                        }
231                        String prefix = String.format(
232                                        "Failure executing task '%s', for driver: %s, aborting! Cause: ", description, getDriverType());
233                        throw new HapiMigrationException(Msg.code(47) + prefix + e, theMigrationResult, e);
234                }
235        }
236
237        private void preExecute(BaseTask theTask) {
238                myCallbacks.forEach(action -> action.preExecution(theTask));
239        }
240
241        private void recordTaskAsCompletedIfNotDryRun(BaseTask theNext, long theExecutionMillis, boolean theSuccess) {
242                if (!theNext.isDryRun()) {
243                        myHapiMigrationStorageSvc.saveTask(theNext, Math.toIntExact(theExecutionMillis), theSuccess);
244                }
245        }
246
247        public void addTasks(Iterable<BaseTask> theMigrationTasks) {
248                if (HapiSystemProperties.isUnitTestModeEnabled()) {
249                        ourLog.info("Skipping tasks because unit test mode is enabled");
250                        // Tests only need to initialize the schemas. No need to run all the migrations for every test.
251                        for (BaseTask task : theMigrationTasks) {
252                                if (task instanceof InitializeSchemaTask) {
253                                        addTask(task);
254                                }
255                        }
256                } else {
257                        myTaskList.append(theMigrationTasks);
258                }
259        }
260
261        /**
262         * Unlike {@link #addTasks(Iterable)}, this method always adds all tasks
263         */
264        public void addAllTasksForUnitTest(Iterable<BaseTask> theMigrationTasks) {
265                myTaskList.append(theMigrationTasks);
266        }
267
268        public void addTask(BaseTask theTask) {
269                // Don't add a check for unit test mode here - We call this from
270                // tests which expect tasks to always be added
271                myTaskList.add(theTask);
272        }
273
274        public void setCallbacks(@Nonnull List<IHapiMigrationCallback> theCallbacks) {
275                Validate.notNull(theCallbacks, "theCallbacks must not be null");
276                myCallbacks = theCallbacks;
277        }
278
279        @VisibleForTesting
280        public void removeAllTasksForUnitTest() {
281                myTaskList.clear();
282        }
283
284        public void createMigrationTableIfRequired() {
285                if (!myDryRun) {
286                        myHapiMigrationStorageSvc.createMigrationTableIfRequired();
287                }
288        }
289}