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.taskdef; 021 022import ca.uhn.fhir.i18n.Msg; 023import ca.uhn.fhir.jpa.migrate.DriverTypeEnum; 024import ca.uhn.fhir.jpa.migrate.HapiMigrationException; 025import ca.uhn.fhir.jpa.migrate.tasks.api.TaskFlagEnum; 026import jakarta.annotation.Nonnull; 027import org.apache.commons.lang3.Validate; 028import org.apache.commons.lang3.builder.EqualsBuilder; 029import org.apache.commons.lang3.builder.HashCodeBuilder; 030import org.apache.commons.lang3.builder.ToStringBuilder; 031import org.apache.commons.lang3.builder.ToStringStyle; 032import org.flywaydb.core.api.MigrationVersion; 033import org.intellij.lang.annotations.Language; 034import org.slf4j.Logger; 035import org.slf4j.LoggerFactory; 036import org.springframework.dao.DataAccessException; 037import org.springframework.jdbc.core.JdbcTemplate; 038import org.springframework.jdbc.core.RowCountCallbackHandler; 039import org.springframework.transaction.support.TransactionTemplate; 040 041import java.sql.SQLException; 042import java.util.ArrayList; 043import java.util.Arrays; 044import java.util.Collections; 045import java.util.EnumSet; 046import java.util.HashSet; 047import java.util.List; 048import java.util.Locale; 049import java.util.Set; 050import java.util.regex.Matcher; 051import java.util.regex.Pattern; 052 053public abstract class BaseTask { 054 055 public static final String MIGRATION_VERSION_PATTERN = "\\d{8}\\.\\d+"; 056 private static final Logger ourLog = LoggerFactory.getLogger(BaseTask.class); 057 private static final Pattern versionPattern = Pattern.compile(MIGRATION_VERSION_PATTERN); 058 private final String myProductVersion; 059 private final String mySchemaVersion; 060 private final List<ExecuteTaskPrecondition> myPreconditions = new ArrayList<>(); 061 private final EnumSet<TaskFlagEnum> myFlags = EnumSet.noneOf(TaskFlagEnum.class); 062 private final List<ExecutedStatement> myExecutedStatements = new ArrayList<>(); 063 /** 064 * Whether to check for existing tables 065 * before generating SQL 066 */ 067 protected boolean myCheckForExistingTables = true; 068 /** 069 * Whether to generate the SQL in a 'readable format' 070 */ 071 protected boolean myPrettyPrint = false; 072 073 private DriverTypeEnum.ConnectionProperties myConnectionProperties; 074 private DriverTypeEnum myDriverType; 075 private String myDescription; 076 private Integer myChangesCount = 0; 077 private MigrationTaskExecutionResultEnum myExecutionResult; 078 private boolean myDryRun; 079 private boolean myTransactional = true; 080 private Set<DriverTypeEnum> myOnlyAppliesToPlatforms = new HashSet<>(); 081 private boolean myNoColumnShrink; 082 083 protected BaseTask(String theProductVersion, String theSchemaVersion) { 084 myProductVersion = theProductVersion; 085 mySchemaVersion = theSchemaVersion; 086 } 087 088 /** 089 * Adds a flag if it's not already present, otherwise this call is ignored. 090 * 091 * @param theFlag The flag, must not be null 092 */ 093 public BaseTask addFlag(@Nonnull TaskFlagEnum theFlag) { 094 myFlags.add(theFlag); 095 return this; 096 } 097 098 /** 099 * Some migrations can not be run in a transaction. 100 * When this is true, {@link BaseTask#executeSql} will run without a transaction 101 */ 102 public void setTransactional(boolean theTransactional) { 103 myTransactional = theTransactional; 104 } 105 106 public void setPrettyPrint(boolean thePrettyPrint) { 107 myPrettyPrint = thePrettyPrint; 108 } 109 110 public void setOnlyAppliesToPlatforms(Set<DriverTypeEnum> theOnlyAppliesToPlatforms) { 111 Validate.notNull(theOnlyAppliesToPlatforms, "theOnlyAppliesToPlatforms must not be null"); 112 myOnlyAppliesToPlatforms = theOnlyAppliesToPlatforms; 113 } 114 115 public String getProductVersion() { 116 return myProductVersion; 117 } 118 119 public String getSchemaVersion() { 120 return mySchemaVersion; 121 } 122 123 public boolean isNoColumnShrink() { 124 return myNoColumnShrink; 125 } 126 127 public void setNoColumnShrink(boolean theNoColumnShrink) { 128 myNoColumnShrink = theNoColumnShrink; 129 } 130 131 public boolean isDryRun() { 132 return myDryRun; 133 } 134 135 public void setDryRun(boolean theDryRun) { 136 myDryRun = theDryRun; 137 } 138 139 public String getDescription() { 140 if (myDescription == null) { 141 return this.getClass().getSimpleName(); 142 } 143 return myDescription; 144 } 145 146 public BaseTask setDescription(String theDescription) { 147 myDescription = theDescription; 148 return this; 149 } 150 151 public List<ExecutedStatement> getExecutedStatements() { 152 return myExecutedStatements; 153 } 154 155 public int getChangesCount() { 156 return myChangesCount; 157 } 158 159 /** 160 * @param theTableName This is only used for logging currently 161 * @param theSql The SQL statement 162 * @param theArguments The SQL statement arguments 163 */ 164 public void executeSql(String theTableName, @Language("SQL") String theSql, Object... theArguments) { 165 if (!isDryRun()) { 166 Integer changes; 167 if (myTransactional) { 168 changes = getConnectionProperties().getTxTemplate().execute(t -> doExecuteSql(theSql, theArguments)); 169 } else { 170 changes = doExecuteSql(theSql, theArguments); 171 } 172 173 myChangesCount += changes; 174 } 175 176 captureExecutedStatement(theTableName, theSql, theArguments); 177 } 178 179 protected void executeSqlListInTransaction(String theTableName, List<String> theSqlStatements) { 180 if (!isDryRun()) { 181 Integer changes; 182 changes = getConnectionProperties().getTxTemplate().execute(t -> doExecuteSqlList(theSqlStatements)); 183 myChangesCount += changes; 184 } 185 186 for (@Language("SQL") String sqlStatement : theSqlStatements) { 187 captureExecutedStatement(theTableName, sqlStatement); 188 } 189 } 190 191 private Integer doExecuteSqlList(List<String> theSqlStatements) { 192 int changesCount = 0; 193 for (@Language("SQL") String nextSql : theSqlStatements) { 194 changesCount += doExecuteSql(nextSql); 195 } 196 197 return changesCount; 198 } 199 200 private int doExecuteSql(@Language("SQL") String theSql, Object... theArguments) { 201 JdbcTemplate jdbcTemplate = getConnectionProperties().newJdbcTemplate(); 202 // 0 means no timeout -- we use this for index rebuilds that may take time. 203 jdbcTemplate.setQueryTimeout(0); 204 try { 205 if (theSql.toUpperCase(Locale.US).startsWith("SELECT ")) { 206 RowCountCallbackHandler rch = new RowCountCallbackHandler(); 207 jdbcTemplate.query(theSql, new Object[0], new int[0], rch); 208 int rows = rch.getRowCount(); 209 logInfo(ourLog, "SQL \"{}\" returned {} rows", theSql, rows); 210 return 0; 211 } else { 212 int changesCount = jdbcTemplate.update(theSql, theArguments); 213 logInfo(ourLog, "SQL \"{}\" returned {}", theSql, changesCount); 214 myExecutionResult = MigrationTaskExecutionResultEnum.APPLIED; 215 return changesCount; 216 } 217 } catch (DataAccessException e) { 218 if (myFlags.contains(TaskFlagEnum.FAILURE_ALLOWED)) { 219 ourLog.info( 220 "Task {} running sql \"{}\"did not exit successfully on doExecuteSql(), but task is allowed to fail", 221 getMigrationVersion(), 222 theSql); 223 ourLog.debug("Error was: {}", e.getMessage(), e); 224 myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_ALLOWED_FAILURE; 225 return 0; 226 } else { 227 throw new HapiMigrationException( 228 Msg.code(61) + "Failed during task " + getMigrationVersion() + ": " + e, e); 229 } 230 } 231 } 232 233 protected void captureExecutedStatement( 234 String theTableName, @Language("SQL") String theSql, Object... theArguments) { 235 myExecutedStatements.add(new ExecutedStatement(mySchemaVersion, theTableName, theSql, theArguments)); 236 } 237 238 public DriverTypeEnum.ConnectionProperties getConnectionProperties() { 239 return myConnectionProperties; 240 } 241 242 public BaseTask setConnectionProperties(DriverTypeEnum.ConnectionProperties theConnectionProperties) { 243 myConnectionProperties = theConnectionProperties; 244 return this; 245 } 246 247 public DriverTypeEnum getDriverType() { 248 return myDriverType; 249 } 250 251 public BaseTask setDriverType(DriverTypeEnum theDriverType) { 252 myDriverType = theDriverType; 253 return this; 254 } 255 256 public abstract void validate(); 257 258 public TransactionTemplate getTxTemplate() { 259 return getConnectionProperties().getTxTemplate(); 260 } 261 262 public JdbcTemplate newJdbcTemplate() { 263 return getConnectionProperties().newJdbcTemplate(); 264 } 265 266 public void execute() throws SQLException { 267 if (myFlags.contains(TaskFlagEnum.DO_NOTHING)) { 268 ourLog.info("Skipping stubbed task: {}", getDescription()); 269 myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_SKIPPED; 270 return; 271 } 272 if (!myOnlyAppliesToPlatforms.isEmpty()) { 273 if (!myOnlyAppliesToPlatforms.contains(getDriverType())) { 274 ourLog.info("Skipping task {} as it does not apply to {}", getDescription(), getDriverType()); 275 myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_NOT_FOR_THIS_DATABASE; 276 return; 277 } 278 } 279 280 for (ExecuteTaskPrecondition precondition : myPreconditions) { 281 ourLog.debug("precondition to evaluate: {}", precondition); 282 if (!precondition.getPreconditionRunner().get()) { 283 ourLog.info( 284 "Skipping task since one of the preconditions was not met: {}", 285 precondition.getPreconditionReason()); 286 myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_PRECONDITION_NOT_MET; 287 return; 288 } 289 } 290 doExecute(); 291 } 292 293 @Override 294 public String toString() { 295 return getClass().getSimpleName() + "[" + getProductVersion() + "." + getSchemaVersion() + "]"; 296 } 297 298 protected abstract void doExecute() throws SQLException; 299 300 public String getMigrationVersion() { 301 String releasePart = myProductVersion; 302 if (releasePart.startsWith("V")) { 303 releasePart = releasePart.substring(1); 304 } 305 String version = releasePart + "." + mySchemaVersion; 306 MigrationVersion migrationVersion = MigrationVersion.fromVersion(version); 307 return migrationVersion.getVersion(); 308 } 309 310 @SuppressWarnings("StringConcatenationArgumentToLogCall") 311 protected void logInfo(Logger theLog, String theFormattedMessage, Object... theArguments) { 312 theLog.info(getMigrationVersion() + ": " + theFormattedMessage, theArguments); 313 } 314 315 public void validateVersion() { 316 Matcher matcher = versionPattern.matcher(mySchemaVersion); 317 if (!matcher.matches()) { 318 throw new IllegalStateException(Msg.code(62) + "The version " + mySchemaVersion 319 + " does not match the expected pattern " + MIGRATION_VERSION_PATTERN); 320 } 321 } 322 323 public void addPrecondition(ExecuteTaskPrecondition thePrecondition) { 324 myPreconditions.add(thePrecondition); 325 } 326 327 @Override 328 public final int hashCode() { 329 HashCodeBuilder builder = new HashCodeBuilder(); 330 generateHashCode(builder); 331 return builder.hashCode(); 332 } 333 334 protected abstract void generateHashCode(HashCodeBuilder theBuilder); 335 336 @Override 337 public final boolean equals(Object theObject) { 338 if (theObject == null || !getClass().equals(theObject.getClass())) { 339 return false; 340 } 341 BaseTask otherObject = (BaseTask) theObject; 342 343 EqualsBuilder b = new EqualsBuilder(); 344 generateEquals(b, otherObject); 345 return b.isEquals(); 346 } 347 348 protected abstract void generateEquals(EqualsBuilder theBuilder, BaseTask theOtherObject); 349 350 public boolean initializedSchema() { 351 return false; 352 } 353 354 public boolean isDoNothing() { 355 return myFlags.contains(TaskFlagEnum.DO_NOTHING); 356 } 357 358 public boolean isHeavyweightSkippableTask() { 359 return myFlags.contains(TaskFlagEnum.HEAVYWEIGHT_SKIP_BY_DEFAULT); 360 } 361 362 public boolean isRunDuringSchemaInitialization() { 363 return myFlags.contains(TaskFlagEnum.RUN_DURING_SCHEMA_INITIALIZATION); 364 } 365 366 public boolean hasFlag(TaskFlagEnum theFlag) { 367 return myFlags.contains(theFlag); 368 } 369 370 public MigrationTaskExecutionResultEnum getExecutionResult() { 371 return myExecutionResult; 372 } 373 374 public static class ExecutedStatement { 375 private final String mySql; 376 private final List<Object> myArguments; 377 private final String myTableName; 378 private final String mySchemaVersion; 379 380 public ExecutedStatement(String theSchemaVersion, String theDescription, String theSql, Object[] theArguments) { 381 mySchemaVersion = theSchemaVersion; 382 myTableName = theDescription; 383 mySql = theSql; 384 myArguments = theArguments != null ? Arrays.asList(theArguments) : Collections.emptyList(); 385 } 386 387 public String getSchemaVersion() { 388 return mySchemaVersion; 389 } 390 391 public String getTableName() { 392 return myTableName; 393 } 394 395 public String getSql() { 396 return mySql; 397 } 398 399 public List<Object> getArguments() { 400 return myArguments; 401 } 402 403 @Override 404 public String toString() { 405 return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) 406 .append("tableName", myTableName) 407 .append("sql", mySql) 408 .append("arguments", myArguments) 409 .toString(); 410 } 411 } 412}