001/*- 002 * #%L 003 * HAPI FHIR Server - SQL Migration 004 * %% 005 * Copyright (C) 2014 - 2025 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 // Tests only need to initialize the schemas. No need to run all the migrations for every test. 250 for (BaseTask task : theMigrationTasks) { 251 if (task instanceof InitializeSchemaTask) { 252 addTask(task); 253 } 254 } 255 } else { 256 myTaskList.append(theMigrationTasks); 257 } 258 } 259 260 /** 261 * Unlike {@link #addTasks(Iterable)}, this method always adds all tasks 262 */ 263 public void addAllTasksForUnitTest(Iterable<BaseTask> theMigrationTasks) { 264 myTaskList.append(theMigrationTasks); 265 } 266 267 public void addTask(BaseTask theTask) { 268 // Don't add a check for unit test mode here - We call this from 269 // tests which expect tasks to always be added 270 myTaskList.add(theTask); 271 } 272 273 public void setCallbacks(@Nonnull List<IHapiMigrationCallback> theCallbacks) { 274 Validate.notNull(theCallbacks, "theCallbacks must not be null"); 275 myCallbacks = theCallbacks; 276 } 277 278 @VisibleForTesting 279 public void removeAllTasksForUnitTest() { 280 myTaskList.clear(); 281 } 282 283 public void createMigrationTableIfRequired() { 284 if (!myDryRun) { 285 myHapiMigrationStorageSvc.createMigrationTableIfRequired(); 286 } 287 } 288}