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}