001/*-
002 * #%L
003 * HAPI FHIR Server - SQL Migration
004 * %%
005 * Copyright (C) 2014 - 2023 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.system.HapiSystemProperties;
026import org.apache.commons.lang3.Validate;
027import org.apache.commons.lang3.builder.EqualsBuilder;
028import org.apache.commons.lang3.builder.HashCodeBuilder;
029import org.flywaydb.core.api.MigrationVersion;
030import org.intellij.lang.annotations.Language;
031import org.slf4j.Logger;
032import org.slf4j.LoggerFactory;
033import org.springframework.dao.DataAccessException;
034import org.springframework.jdbc.core.JdbcTemplate;
035import org.springframework.transaction.support.TransactionTemplate;
036
037import java.sql.SQLException;
038import java.util.ArrayList;
039import java.util.Arrays;
040import java.util.Collections;
041import java.util.HashSet;
042import java.util.List;
043import java.util.Set;
044import java.util.regex.Matcher;
045import java.util.regex.Pattern;
046
047public abstract class BaseTask {
048
049        public static final String MIGRATION_VERSION_PATTERN = "\\d{8}\\.\\d+";
050        private static final Logger ourLog = LoggerFactory.getLogger(BaseTask.class);
051        private static final Pattern versionPattern = Pattern.compile(MIGRATION_VERSION_PATTERN);
052        private final String myProductVersion;
053        private final String mySchemaVersion;
054        private DriverTypeEnum.ConnectionProperties myConnectionProperties;
055        private DriverTypeEnum myDriverType;
056        private String myDescription;
057        private Integer myChangesCount = 0;
058        private boolean myDryRun;
059
060        /**
061         * Some migrations can not be run in a transaction.
062         * When this is true, {@link BaseTask#executeSql} will run without a transaction
063         */
064        public void setTransactional(boolean theTransactional) {
065                myTransactional = theTransactional;
066        }
067
068        private boolean myTransactional = true;
069        private boolean myDoNothing;
070        private List<ExecutedStatement> myExecutedStatements = new ArrayList<>();
071        private Set<DriverTypeEnum> myOnlyAppliesToPlatforms = new HashSet<>();
072        private boolean myNoColumnShrink;
073        private boolean myFailureAllowed;
074        private boolean myRunDuringSchemaInitialization;
075        /**
076         * Whether or not to check for existing tables
077         * before generating SQL
078         */
079        protected boolean myCheckForExistingTables = true;
080
081        /**
082         * Whether or not to generate the SQL in a 'readable format'
083         */
084        protected boolean myPrettyPrint = false;
085
086        protected BaseTask(String theProductVersion, String theSchemaVersion) {
087                myProductVersion = theProductVersion;
088                mySchemaVersion = theSchemaVersion;
089        }
090
091        public boolean isRunDuringSchemaInitialization() {
092                return myRunDuringSchemaInitialization;
093        }
094
095        public void setPrettyPrint(boolean thePrettyPrint) {
096                myPrettyPrint = thePrettyPrint;
097        }
098
099        /**
100         * Should this task run even if we're doing the very first initialization of an empty schema. By
101         * default we skip most tasks during that pass, since they just take up time and the
102         * schema should be fully initialized by the {@link InitializeSchemaTask}
103         */
104        public void setRunDuringSchemaInitialization(boolean theRunDuringSchemaInitialization) {
105                myRunDuringSchemaInitialization = theRunDuringSchemaInitialization;
106        }
107
108        public void setOnlyAppliesToPlatforms(Set<DriverTypeEnum> theOnlyAppliesToPlatforms) {
109                Validate.notNull(theOnlyAppliesToPlatforms);
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 (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 (myFailureAllowed) {
210                                ourLog.info("Task {} did not exit successfully, but task is allowed to fail", getMigrationVersion());
211                                ourLog.debug("Error was: {}", e.getMessage(), e);
212                                return 0;
213                        } else {
214                                throw new HapiMigrationException(Msg.code(61) + "Failed during task " + getMigrationVersion() + ": " + e, e);
215                        }
216                }
217        }
218
219        protected void captureExecutedStatement(String theTableName, @Language("SQL") String theSql, Object... theArguments) {
220                myExecutedStatements.add(new ExecutedStatement(theTableName, theSql, theArguments));
221        }
222
223        public DriverTypeEnum.ConnectionProperties getConnectionProperties() {
224                return myConnectionProperties;
225        }
226
227        public BaseTask setConnectionProperties(DriverTypeEnum.ConnectionProperties theConnectionProperties) {
228                myConnectionProperties = theConnectionProperties;
229                return this;
230        }
231
232        public DriverTypeEnum getDriverType() {
233                return myDriverType;
234        }
235
236        public BaseTask setDriverType(DriverTypeEnum theDriverType) {
237                myDriverType = theDriverType;
238                return this;
239        }
240
241        public abstract void validate();
242
243        public TransactionTemplate getTxTemplate() {
244                return getConnectionProperties().getTxTemplate();
245        }
246
247        public JdbcTemplate newJdbcTemplate() {
248                return getConnectionProperties().newJdbcTemplate();
249        }
250
251        public void execute() throws SQLException {
252                if (myDoNothing) {
253                        ourLog.info("Skipping stubbed task: {}", getDescription());
254                        return;
255                }
256                if (!myOnlyAppliesToPlatforms.isEmpty()) {
257                        if (!myOnlyAppliesToPlatforms.contains(getDriverType())) {
258                                ourLog.debug("Skipping task {} as it does not apply to {}", getDescription(), getDriverType());
259                                return;
260                        }
261                }
262                doExecute();
263        }
264
265        protected abstract void doExecute() throws SQLException;
266
267        protected boolean isFailureAllowed() {
268                return myFailureAllowed;
269        }
270
271        public void setFailureAllowed(boolean theFailureAllowed) {
272                myFailureAllowed = theFailureAllowed;
273        }
274
275        public String getMigrationVersion() {
276                String releasePart = myProductVersion;
277                if (releasePart.startsWith("V")) {
278                        releasePart = releasePart.substring(1);
279                }
280                String version = releasePart + "." + mySchemaVersion;
281                MigrationVersion migrationVersion = MigrationVersion.fromVersion(version);
282                return migrationVersion.getVersion();
283        }
284
285        protected void logInfo(Logger theLog, String theFormattedMessage, Object... theArguments) {
286                theLog.info(getMigrationVersion() + ": " + theFormattedMessage, theArguments);
287        }
288
289        public void validateVersion() {
290                Matcher matcher = versionPattern.matcher(mySchemaVersion);
291                if (!matcher.matches()) {
292                        throw new IllegalStateException(Msg.code(62) + "The version " + mySchemaVersion + " does not match the expected pattern " + MIGRATION_VERSION_PATTERN);
293                }
294        }
295
296        public boolean isDoNothing() {
297                return myDoNothing;
298        }
299
300        public BaseTask setDoNothing(boolean theDoNothing) {
301                myDoNothing = theDoNothing;
302                return this;
303        }
304
305        @Override
306        public final int hashCode() {
307                HashCodeBuilder builder = new HashCodeBuilder();
308                generateHashCode(builder);
309                return builder.hashCode();
310        }
311
312        protected abstract void generateHashCode(HashCodeBuilder theBuilder);
313
314        @Override
315        public final boolean equals(Object theObject) {
316                if (theObject == null || getClass().equals(theObject.getClass()) == false) {
317                        return false;
318                }
319                @SuppressWarnings("unchecked")
320                BaseTask otherObject = (BaseTask) theObject;
321
322                EqualsBuilder b = new EqualsBuilder();
323                generateEquals(b, otherObject);
324                return b.isEquals();
325        }
326
327        protected abstract void generateEquals(EqualsBuilder theBuilder, BaseTask theOtherObject);
328
329        public boolean initializedSchema() {
330                return false;
331        }
332
333        public static class ExecutedStatement {
334                private final String mySql;
335                private final List<Object> myArguments;
336                private final String myTableName;
337
338                public ExecutedStatement(String theDescription, String theSql, Object[] theArguments) {
339                        myTableName = theDescription;
340                        mySql = theSql;
341                        myArguments = theArguments != null ? Arrays.asList(theArguments) : Collections.emptyList();
342                }
343
344                public String getTableName() {
345                        return myTableName;
346                }
347
348                public String getSql() {
349                        return mySql;
350                }
351
352                public List<Object> getArguments() {
353                        return myArguments;
354                }
355        }
356}