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 {} did not exit successfully on doExecuteSql(), but task is allowed to fail",
221                                                getMigrationVersion());
222                                ourLog.debug("Error was: {}", e.getMessage(), e);
223                                myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_ALLOWED_FAILURE;
224                                return 0;
225                        } else {
226                                throw new HapiMigrationException(
227                                                Msg.code(61) + "Failed during task " + getMigrationVersion() + ": " + e, e);
228                        }
229                }
230        }
231
232        protected void captureExecutedStatement(
233                        String theTableName, @Language("SQL") String theSql, Object... theArguments) {
234                myExecutedStatements.add(new ExecutedStatement(mySchemaVersion, theTableName, theSql, theArguments));
235        }
236
237        public DriverTypeEnum.ConnectionProperties getConnectionProperties() {
238                return myConnectionProperties;
239        }
240
241        public BaseTask setConnectionProperties(DriverTypeEnum.ConnectionProperties theConnectionProperties) {
242                myConnectionProperties = theConnectionProperties;
243                return this;
244        }
245
246        public DriverTypeEnum getDriverType() {
247                return myDriverType;
248        }
249
250        public BaseTask setDriverType(DriverTypeEnum theDriverType) {
251                myDriverType = theDriverType;
252                return this;
253        }
254
255        public abstract void validate();
256
257        public TransactionTemplate getTxTemplate() {
258                return getConnectionProperties().getTxTemplate();
259        }
260
261        public JdbcTemplate newJdbcTemplate() {
262                return getConnectionProperties().newJdbcTemplate();
263        }
264
265        public void execute() throws SQLException {
266                if (myFlags.contains(TaskFlagEnum.DO_NOTHING)) {
267                        ourLog.info("Skipping stubbed task: {}", getDescription());
268                        myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_SKIPPED;
269                        return;
270                }
271                if (!myOnlyAppliesToPlatforms.isEmpty()) {
272                        if (!myOnlyAppliesToPlatforms.contains(getDriverType())) {
273                                ourLog.info("Skipping task {} as it does not apply to {}", getDescription(), getDriverType());
274                                myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_NOT_FOR_THIS_DATABASE;
275                                return;
276                        }
277                }
278
279                for (ExecuteTaskPrecondition precondition : myPreconditions) {
280                        ourLog.debug("precondition to evaluate: {}", precondition);
281                        if (!precondition.getPreconditionRunner().get()) {
282                                ourLog.info(
283                                                "Skipping task since one of the preconditions was not met: {}",
284                                                precondition.getPreconditionReason());
285                                myExecutionResult = MigrationTaskExecutionResultEnum.NOT_APPLIED_PRECONDITION_NOT_MET;
286                                return;
287                        }
288                }
289                doExecute();
290        }
291
292        @Override
293        public String toString() {
294                return getClass().getSimpleName() + "[" + getProductVersion() + "." + getSchemaVersion() + "]";
295        }
296
297        protected abstract void doExecute() throws SQLException;
298
299        public String getMigrationVersion() {
300                String releasePart = myProductVersion;
301                if (releasePart.startsWith("V")) {
302                        releasePart = releasePart.substring(1);
303                }
304                String version = releasePart + "." + mySchemaVersion;
305                MigrationVersion migrationVersion = MigrationVersion.fromVersion(version);
306                return migrationVersion.getVersion();
307        }
308
309        @SuppressWarnings("StringConcatenationArgumentToLogCall")
310        protected void logInfo(Logger theLog, String theFormattedMessage, Object... theArguments) {
311                theLog.info(getMigrationVersion() + ": " + theFormattedMessage, theArguments);
312        }
313
314        public void validateVersion() {
315                Matcher matcher = versionPattern.matcher(mySchemaVersion);
316                if (!matcher.matches()) {
317                        throw new IllegalStateException(Msg.code(62) + "The version " + mySchemaVersion
318                                        + " does not match the expected pattern " + MIGRATION_VERSION_PATTERN);
319                }
320        }
321
322        public void addPrecondition(ExecuteTaskPrecondition thePrecondition) {
323                myPreconditions.add(thePrecondition);
324        }
325
326        @Override
327        public final int hashCode() {
328                HashCodeBuilder builder = new HashCodeBuilder();
329                generateHashCode(builder);
330                return builder.hashCode();
331        }
332
333        protected abstract void generateHashCode(HashCodeBuilder theBuilder);
334
335        @Override
336        public final boolean equals(Object theObject) {
337                if (theObject == null || getClass().equals(theObject.getClass()) == false) {
338                        return false;
339                }
340                BaseTask otherObject = (BaseTask) theObject;
341
342                EqualsBuilder b = new EqualsBuilder();
343                generateEquals(b, otherObject);
344                return b.isEquals();
345        }
346
347        protected abstract void generateEquals(EqualsBuilder theBuilder, BaseTask theOtherObject);
348
349        public boolean initializedSchema() {
350                return false;
351        }
352
353        public boolean isDoNothing() {
354                return myFlags.contains(TaskFlagEnum.DO_NOTHING);
355        }
356
357        public boolean isHeavyweightSkippableTask() {
358                return myFlags.contains(TaskFlagEnum.HEAVYWEIGHT_SKIP_BY_DEFAULT);
359        }
360
361        public boolean hasFlag(TaskFlagEnum theFlag) {
362                return myFlags.contains(theFlag);
363        }
364
365        public MigrationTaskExecutionResultEnum getExecutionResult() {
366                return myExecutionResult;
367        }
368
369        public static class ExecutedStatement {
370                private final String mySql;
371                private final List<Object> myArguments;
372                private final String myTableName;
373                private final String mySchemaVersion;
374
375                public ExecutedStatement(String theSchemaVersion, String theDescription, String theSql, Object[] theArguments) {
376                        mySchemaVersion = theSchemaVersion;
377                        myTableName = theDescription;
378                        mySql = theSql;
379                        myArguments = theArguments != null ? Arrays.asList(theArguments) : Collections.emptyList();
380                }
381
382                public String getSchemaVersion() {
383                        return mySchemaVersion;
384                }
385
386                public String getTableName() {
387                        return myTableName;
388                }
389
390                public String getSql() {
391                        return mySql;
392                }
393
394                public List<Object> getArguments() {
395                        return myArguments;
396                }
397
398                @Override
399                public String toString() {
400                        return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
401                                        .append("tableName", myTableName)
402                                        .append("sql", mySql)
403                                        .append("arguments", myArguments)
404                                        .toString();
405                }
406        }
407}