001package ca.uhn.fhir.jpa.migrate.taskdef;
002
003/*-
004 * #%L
005 * HAPI FHIR Server - SQL Migration
006 * %%
007 * Copyright (C) 2014 - 2023 Smile CDR, Inc.
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 *
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 *
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.jpa.migrate.DriverTypeEnum;
025import ca.uhn.fhir.jpa.migrate.HapiMigrationException;
026import ca.uhn.fhir.system.HapiSystemProperties;
027import org.apache.commons.lang3.Validate;
028import org.apache.commons.lang3.builder.EqualsBuilder;
029import org.apache.commons.lang3.builder.HashCodeBuilder;
030import org.flywaydb.core.api.MigrationVersion;
031import org.intellij.lang.annotations.Language;
032import org.slf4j.Logger;
033import org.slf4j.LoggerFactory;
034import org.springframework.dao.DataAccessException;
035import org.springframework.jdbc.core.JdbcTemplate;
036import org.springframework.transaction.support.TransactionTemplate;
037
038import java.sql.SQLException;
039import java.util.ArrayList;
040import java.util.Arrays;
041import java.util.Collections;
042import java.util.HashSet;
043import java.util.List;
044import java.util.Set;
045import java.util.regex.Matcher;
046import java.util.regex.Pattern;
047
048public abstract class BaseTask {
049
050        public static final String MIGRATION_VERSION_PATTERN = "\\d{8}\\.\\d+";
051        private static final Logger ourLog = LoggerFactory.getLogger(BaseTask.class);
052        private static final Pattern versionPattern = Pattern.compile(MIGRATION_VERSION_PATTERN);
053        private final String myProductVersion;
054        private final String mySchemaVersion;
055        private DriverTypeEnum.ConnectionProperties myConnectionProperties;
056        private DriverTypeEnum myDriverType;
057        private String myDescription;
058        private Integer myChangesCount = 0;
059        private boolean myDryRun;
060
061        /**
062         * Some migrations can not be run in a transaction.
063         * When this is true, {@link BaseTask#executeSql} will run without a transaction
064         */
065        public void setTransactional(boolean theTransactional) {
066                myTransactional = theTransactional;
067        }
068
069        private boolean myTransactional = true;
070        private boolean myDoNothing;
071        private List<ExecutedStatement> myExecutedStatements = new ArrayList<>();
072        private Set<DriverTypeEnum> myOnlyAppliesToPlatforms = new HashSet<>();
073        private boolean myNoColumnShrink;
074        private boolean myFailureAllowed;
075        private boolean myRunDuringSchemaInitialization;
076        /**
077         * Whether or not to check for existing tables
078         * before generating SQL
079         */
080        protected boolean myCheckForExistingTables = true;
081
082        /**
083         * Whether or not to generate the SQL in a 'readable format'
084         */
085        protected boolean myPrettyPrint = false;
086
087        protected BaseTask(String theProductVersion, String theSchemaVersion) {
088                myProductVersion = theProductVersion;
089                mySchemaVersion = theSchemaVersion;
090        }
091
092        public boolean isRunDuringSchemaInitialization() {
093                return myRunDuringSchemaInitialization;
094        }
095
096        public void setPrettyPrint(boolean thePrettyPrint) {
097                myPrettyPrint = thePrettyPrint;
098        }
099
100        /**
101         * Should this task run even if we're doing the very first initialization of an empty schema. By
102         * default we skip most tasks during that pass, since they just take up time and the
103         * schema should be fully initialized by the {@link InitializeSchemaTask}
104         */
105        public void setRunDuringSchemaInitialization(boolean theRunDuringSchemaInitialization) {
106                myRunDuringSchemaInitialization = theRunDuringSchemaInitialization;
107        }
108
109        public void setOnlyAppliesToPlatforms(Set<DriverTypeEnum> theOnlyAppliesToPlatforms) {
110                Validate.notNull(theOnlyAppliesToPlatforms);
111                myOnlyAppliesToPlatforms = theOnlyAppliesToPlatforms;
112        }
113
114        public String getProductVersion() {
115                return myProductVersion;
116        }
117
118        public String getSchemaVersion() {
119                return mySchemaVersion;
120        }
121
122        public boolean isNoColumnShrink() {
123                return myNoColumnShrink;
124        }
125
126        public void setNoColumnShrink(boolean theNoColumnShrink) {
127                myNoColumnShrink = theNoColumnShrink;
128        }
129
130        public boolean isDryRun() {
131                return myDryRun;
132        }
133
134        public void setDryRun(boolean theDryRun) {
135                myDryRun = theDryRun;
136        }
137
138        public String getDescription() {
139                if (myDescription == null) {
140                        return this.getClass().getSimpleName();
141                }
142                return myDescription;
143        }
144
145        public BaseTask setDescription(String theDescription) {
146                myDescription = theDescription;
147                return this;
148        }
149
150        public List<ExecutedStatement> getExecutedStatements() {
151                return myExecutedStatements;
152        }
153
154        public int getChangesCount() {
155                return myChangesCount;
156        }
157
158        /**
159         * @param theTableName This is only used for logging currently
160         * @param theSql       The SQL statement
161         * @param theArguments The SQL statement arguments
162         */
163        public void executeSql(String theTableName, @Language("SQL") String theSql, Object... theArguments) {
164                if (!isDryRun()) {
165                        Integer changes;
166                        if (myTransactional) {
167                                changes = getConnectionProperties().getTxTemplate().execute(t -> doExecuteSql(theSql, theArguments));
168                        } else {
169                                changes = doExecuteSql(theSql, theArguments);
170                        }
171
172                        myChangesCount += changes;
173                }
174
175                captureExecutedStatement(theTableName, theSql, theArguments);
176        }
177
178        protected void executeSqlListInTransaction(String theTableName, List<String> theSqlStatements) {
179                if (!isDryRun()) {
180                        Integer changes;
181                        changes = getConnectionProperties().getTxTemplate().execute(t -> doExecuteSqlList(theSqlStatements));
182                        myChangesCount += changes;
183                }
184
185                for (@Language("SQL") String sqlStatement : theSqlStatements) {
186                        captureExecutedStatement(theTableName, sqlStatement);
187                }
188        }
189
190        private Integer doExecuteSqlList(List<String> theSqlStatements) {
191                int changesCount = 0;
192                for (String nextSql : theSqlStatements) {
193                        changesCount += doExecuteSql(nextSql);
194                }
195
196                return changesCount;
197        }
198
199        private int doExecuteSql(@Language("SQL") String theSql, Object... theArguments) {
200                JdbcTemplate jdbcTemplate = getConnectionProperties().newJdbcTemplate();
201                // 0 means no timeout -- we use this for index rebuilds that may take time.
202                jdbcTemplate.setQueryTimeout(0);
203                try {
204                        int changesCount = jdbcTemplate.update(theSql, theArguments);
205                        if (!HapiSystemProperties.isUnitTestModeEnabled()) {
206                                logInfo(ourLog, "SQL \"{}\" returned {}", theSql, changesCount);
207                        }
208                        return changesCount;
209                } catch (DataAccessException e) {
210                        if (myFailureAllowed) {
211                                ourLog.info("Task {} did not exit successfully, but task is allowed to fail", getMigrationVersion());
212                                ourLog.debug("Error was: {}", e.getMessage(), e);
213                                return 0;
214                        } else {
215                                throw new HapiMigrationException(Msg.code(61) + "Failed during task " + getMigrationVersion() + ": " + e, e);
216                        }
217                }
218        }
219
220        protected void captureExecutedStatement(String theTableName, @Language("SQL") String theSql, Object... theArguments) {
221                myExecutedStatements.add(new ExecutedStatement(theTableName, theSql, theArguments));
222        }
223
224        public DriverTypeEnum.ConnectionProperties getConnectionProperties() {
225                return myConnectionProperties;
226        }
227
228        public BaseTask setConnectionProperties(DriverTypeEnum.ConnectionProperties theConnectionProperties) {
229                myConnectionProperties = theConnectionProperties;
230                return this;
231        }
232
233        public DriverTypeEnum getDriverType() {
234                return myDriverType;
235        }
236
237        public BaseTask setDriverType(DriverTypeEnum theDriverType) {
238                myDriverType = theDriverType;
239                return this;
240        }
241
242        public abstract void validate();
243
244        public TransactionTemplate getTxTemplate() {
245                return getConnectionProperties().getTxTemplate();
246        }
247
248        public JdbcTemplate newJdbcTemplate() {
249                return getConnectionProperties().newJdbcTemplate();
250        }
251
252        public void execute() throws SQLException {
253                if (myDoNothing) {
254                        ourLog.info("Skipping stubbed task: {}", getDescription());
255                        return;
256                }
257                if (!myOnlyAppliesToPlatforms.isEmpty()) {
258                        if (!myOnlyAppliesToPlatforms.contains(getDriverType())) {
259                                ourLog.debug("Skipping task {} as it does not apply to {}", getDescription(), getDriverType());
260                                return;
261                        }
262                }
263                doExecute();
264        }
265
266        protected abstract void doExecute() throws SQLException;
267
268        protected boolean isFailureAllowed() {
269                return myFailureAllowed;
270        }
271
272        public void setFailureAllowed(boolean theFailureAllowed) {
273                myFailureAllowed = theFailureAllowed;
274        }
275
276        public String getMigrationVersion() {
277                String releasePart = myProductVersion;
278                if (releasePart.startsWith("V")) {
279                        releasePart = releasePart.substring(1);
280                }
281                String version = releasePart + "." + mySchemaVersion;
282                MigrationVersion migrationVersion = MigrationVersion.fromVersion(version);
283                return migrationVersion.getVersion();
284        }
285
286        protected void logInfo(Logger theLog, String theFormattedMessage, Object... theArguments) {
287                theLog.info(getMigrationVersion() + ": " + theFormattedMessage, theArguments);
288        }
289
290        public void validateVersion() {
291                Matcher matcher = versionPattern.matcher(mySchemaVersion);
292                if (!matcher.matches()) {
293                        throw new IllegalStateException(Msg.code(62) + "The version " + mySchemaVersion + " does not match the expected pattern " + MIGRATION_VERSION_PATTERN);
294                }
295        }
296
297        public boolean isDoNothing() {
298                return myDoNothing;
299        }
300
301        public BaseTask setDoNothing(boolean theDoNothing) {
302                myDoNothing = theDoNothing;
303                return this;
304        }
305
306        @Override
307        public final int hashCode() {
308                HashCodeBuilder builder = new HashCodeBuilder();
309                generateHashCode(builder);
310                return builder.hashCode();
311        }
312
313        protected abstract void generateHashCode(HashCodeBuilder theBuilder);
314
315        @Override
316        public final boolean equals(Object theObject) {
317                if (theObject == null || getClass().equals(theObject.getClass()) == false) {
318                        return false;
319                }
320                @SuppressWarnings("unchecked")
321                BaseTask otherObject = (BaseTask) theObject;
322
323                EqualsBuilder b = new EqualsBuilder();
324                generateEquals(b, otherObject);
325                return b.isEquals();
326        }
327
328        protected abstract void generateEquals(EqualsBuilder theBuilder, BaseTask theOtherObject);
329
330        public boolean initializedSchema() {
331                return false;
332        }
333
334        public static class ExecutedStatement {
335                private final String mySql;
336                private final List<Object> myArguments;
337                private final String myTableName;
338
339                public ExecutedStatement(String theDescription, String theSql, Object[] theArguments) {
340                        myTableName = theDescription;
341                        mySql = theSql;
342                        myArguments = theArguments != null ? Arrays.asList(theArguments) : Collections.emptyList();
343                }
344
345                public String getTableName() {
346                        return myTableName;
347                }
348
349                public String getSql() {
350                        return mySql;
351                }
352
353                public List<Object> getArguments() {
354                        return myArguments;
355                }
356        }
357}