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.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(
215                                                Msg.code(61) + "Failed during task " + getMigrationVersion() + ": " + e, e);
216                        }
217                }
218        }
219
220        protected void captureExecutedStatement(
221                        String theTableName, @Language("SQL") String theSql, Object... theArguments) {
222                myExecutedStatements.add(new ExecutedStatement(theTableName, theSql, theArguments));
223        }
224
225        public DriverTypeEnum.ConnectionProperties getConnectionProperties() {
226                return myConnectionProperties;
227        }
228
229        public BaseTask setConnectionProperties(DriverTypeEnum.ConnectionProperties theConnectionProperties) {
230                myConnectionProperties = theConnectionProperties;
231                return this;
232        }
233
234        public DriverTypeEnum getDriverType() {
235                return myDriverType;
236        }
237
238        public BaseTask setDriverType(DriverTypeEnum theDriverType) {
239                myDriverType = theDriverType;
240                return this;
241        }
242
243        public abstract void validate();
244
245        public TransactionTemplate getTxTemplate() {
246                return getConnectionProperties().getTxTemplate();
247        }
248
249        public JdbcTemplate newJdbcTemplate() {
250                return getConnectionProperties().newJdbcTemplate();
251        }
252
253        private final List<ExecuteTaskPrecondition> myPreconditions = new ArrayList<>();
254
255        public void execute() throws SQLException {
256                if (myDoNothing) {
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        protected boolean isFailureAllowed() {
282                return myFailureAllowed;
283        }
284
285        public void setFailureAllowed(boolean theFailureAllowed) {
286                myFailureAllowed = theFailureAllowed;
287        }
288
289        public String getMigrationVersion() {
290                String releasePart = myProductVersion;
291                if (releasePart.startsWith("V")) {
292                        releasePart = releasePart.substring(1);
293                }
294                String version = releasePart + "." + mySchemaVersion;
295                MigrationVersion migrationVersion = MigrationVersion.fromVersion(version);
296                return migrationVersion.getVersion();
297        }
298
299        protected void logInfo(Logger theLog, String theFormattedMessage, Object... theArguments) {
300                theLog.info(getMigrationVersion() + ": " + theFormattedMessage, theArguments);
301        }
302
303        public void validateVersion() {
304                Matcher matcher = versionPattern.matcher(mySchemaVersion);
305                if (!matcher.matches()) {
306                        throw new IllegalStateException(Msg.code(62) + "The version " + mySchemaVersion
307                                        + " does not match the expected pattern " + MIGRATION_VERSION_PATTERN);
308                }
309        }
310
311        public boolean isDoNothing() {
312                return myDoNothing;
313        }
314
315        public BaseTask setDoNothing(boolean theDoNothing) {
316                myDoNothing = theDoNothing;
317                return this;
318        }
319
320        public void addPrecondition(ExecuteTaskPrecondition thePrecondition) {
321                myPreconditions.add(thePrecondition);
322        }
323
324        @Override
325        public final int hashCode() {
326                HashCodeBuilder builder = new HashCodeBuilder();
327                generateHashCode(builder);
328                return builder.hashCode();
329        }
330
331        protected abstract void generateHashCode(HashCodeBuilder theBuilder);
332
333        @Override
334        public final boolean equals(Object theObject) {
335                if (theObject == null || getClass().equals(theObject.getClass()) == false) {
336                        return false;
337                }
338                @SuppressWarnings("unchecked")
339                BaseTask otherObject = (BaseTask) theObject;
340
341                EqualsBuilder b = new EqualsBuilder();
342                generateEquals(b, otherObject);
343                return b.isEquals();
344        }
345
346        protected abstract void generateEquals(EqualsBuilder theBuilder, BaseTask theOtherObject);
347
348        public boolean initializedSchema() {
349                return false;
350        }
351
352        public static class ExecutedStatement {
353                private final String mySql;
354                private final List<Object> myArguments;
355                private final String myTableName;
356
357                public ExecutedStatement(String theDescription, String theSql, Object[] theArguments) {
358                        myTableName = theDescription;
359                        mySql = theSql;
360                        myArguments = theArguments != null ? Arrays.asList(theArguments) : Collections.emptyList();
361                }
362
363                public String getTableName() {
364                        return myTableName;
365                }
366
367                public String getSql() {
368                        return mySql;
369                }
370
371                public List<Object> getArguments() {
372                        return myArguments;
373                }
374        }
375}