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