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.jpa.migrate.DriverTypeEnum; 023import ca.uhn.fhir.jpa.migrate.JdbcUtils; 024import jakarta.annotation.Nonnull; 025import org.apache.commons.lang3.Validate; 026import org.apache.commons.lang3.builder.EqualsBuilder; 027import org.apache.commons.lang3.builder.HashCodeBuilder; 028import org.slf4j.Logger; 029import org.slf4j.LoggerFactory; 030 031import java.sql.SQLException; 032import java.util.Arrays; 033import java.util.Collections; 034import java.util.List; 035import java.util.Locale; 036import java.util.Objects; 037import java.util.Set; 038import java.util.stream.Collectors; 039 040public class AddIndexTask extends BaseTableTask { 041 042 static final Logger ourLog = LoggerFactory.getLogger(AddIndexTask.class); 043 044 private String myIndexName; 045 private List<String> myColumns; 046 private List<String> myNullableColumns; 047 private Boolean myUnique = false; 048 private List<String> myIncludeColumns = Collections.emptyList(); 049 /** Should the operation avoid taking a lock on the table */ 050 private boolean myOnline; 051 052 private MetadataSource myMetadataSource = new MetadataSource(); 053 054 public AddIndexTask(String theProductVersion, String theSchemaVersion) { 055 super(theProductVersion, theSchemaVersion); 056 } 057 058 public void setIndexName(String theIndexName) { 059 myIndexName = theIndexName.toUpperCase(Locale.US); 060 } 061 062 public void setColumns(List<String> theColumns) { 063 myColumns = theColumns; 064 } 065 066 public void setUnique(boolean theUnique) { 067 myUnique = theUnique; 068 } 069 070 public List<String> getNullableColumns() { 071 return myNullableColumns; 072 } 073 074 public void setNullableColumns(List<String> theNullableColumns) { 075 this.myNullableColumns = theNullableColumns; 076 } 077 078 @Override 079 public void validate() { 080 super.validate(); 081 Validate.notBlank(myIndexName, "Index name not specified"); 082 Validate.isTrue( 083 !myColumns.isEmpty(), 084 "Columns not specified for AddIndexTask " + myIndexName + " on table " + getTableName()); 085 Validate.notNull(myUnique, "Uniqueness not specified"); 086 setDescription("Add " + myIndexName + " index to table " + getTableName()); 087 } 088 089 @Override 090 public void doExecute() throws SQLException { 091 Set<String> indexNames = JdbcUtils.getIndexNames(getConnectionProperties(), getTableName()); 092 if (indexNames.contains(myIndexName)) { 093 logInfo(ourLog, "Index {} already exists on table {} - No action performed", myIndexName, getTableName()); 094 return; 095 } 096 097 logInfo( 098 ourLog, 099 "Going to add a {} index named {} on table {} for columns {}", 100 (myUnique ? "UNIQUE" : "NON-UNIQUE"), 101 myIndexName, 102 getTableName(), 103 myColumns); 104 105 String sql = generateSql(); 106 String tableName = getTableName(); 107 108 try { 109 executeSql(tableName, sql); 110 } catch (Exception e) { 111 String message = e.toString(); 112 if (message.contains("already exists") 113 || 114 // The Oracle message is ORA-01408: such column list already indexed 115 // TODO KHS consider db-specific handling here that uses the error code instead of the message so 116 // this is language independent 117 // e.g. if the db is Oracle than checking e.getErrorCode() == 1408 should detect this case 118 message.contains("already indexed")) { 119 ourLog.warn("Index {} already exists: {}", myIndexName, e.getMessage()); 120 } else { 121 throw e; 122 } 123 } 124 } 125 126 @Nonnull 127 String generateSql() { 128 String unique = myUnique ? "unique " : ""; 129 String columns = String.join(", ", myColumns); 130 String includeClause = ""; 131 String mssqlWhereClause = ""; 132 if (!myIncludeColumns.isEmpty()) { 133 switch (getDriverType()) { 134 case POSTGRES_9_4: 135 case MSSQL_2012: 136 case COCKROACHDB_21_1: 137 includeClause = " INCLUDE (" + String.join(", ", myIncludeColumns) + ")"; 138 break; 139 case H2_EMBEDDED: 140 case DERBY_EMBEDDED: 141 case MARIADB_10_1: 142 case MYSQL_5_7: 143 case ORACLE_12C: 144 // These platforms don't support the include clause 145 // Per: 146 // https://use-the-index-luke.com/blog/2019-04/include-columns-in-btree-indexes#postgresql-limitations 147 break; 148 } 149 } 150 if (myUnique && getDriverType() == DriverTypeEnum.MSSQL_2012) { 151 mssqlWhereClause = buildMSSqlNotNullWhereClause(); 152 } 153 // Should we do this non-transactionally? Avoids a write-lock, but introduces weird failure modes. 154 String postgresOnlineClause = ""; 155 String oracleOnlineClause = ""; 156 if (myOnline) { 157 switch (getDriverType()) { 158 case POSTGRES_9_4: 159 case COCKROACHDB_21_1: 160 postgresOnlineClause = "CONCURRENTLY "; 161 // This runs without a lock, and can't be done transactionally. 162 setTransactional(false); 163 break; 164 case MSSQL_2012: 165 // handled below in buildOnlineCreateWithTryCatchFallback() 166 break; 167 case ORACLE_12C: 168 // todo: delete this once we figure out how run Oracle try-catch to match MSSQL. 169 if (myMetadataSource.isOnlineIndexSupported(getConnectionProperties())) { 170 oracleOnlineClause = " ONLINE DEFERRED INVALIDATION"; 171 } 172 break; 173 default: 174 } 175 } 176 177 String bareCreateSql = "create " + unique + "index " + postgresOnlineClause + myIndexName + " on " 178 + getTableName() + "(" + columns + ")" + includeClause + mssqlWhereClause + oracleOnlineClause; 179 180 String sql; 181 if (myOnline && DriverTypeEnum.MSSQL_2012 == getDriverType()) { 182 sql = buildOnlineCreateWithTryCatchFallback(bareCreateSql); 183 } else { 184 sql = bareCreateSql; 185 } 186 return sql; 187 } 188 189 /** 190 * Wrap a Sql Server create index in a try/catch to try it first ONLINE 191 * (meaning no table locks), and on failure, without ONLINE (locking the table). 192 * 193 * This try-catch syntax was manually tested via sql 194 * {@code 195 * BEGIN TRY 196 * EXEC('create index FOO on TABLE_A (col1) WITH (ONLINE = ON)'); 197 * select 'Online-OK'; 198 * END TRY 199 * BEGIN CATCH 200 * create index FOO on TABLE_A (col1); 201 * select 'Offline'; 202 * END CATCH; 203 * -- Then inspect the result set - Online-OK means it ran the ONLINE version. 204 * -- Note: we use EXEC() in the online path to lower the severity of the error 205 * -- so the CATCH can catch it. 206 * } 207 * 208 * @param bareCreateSql 209 * @return 210 */ 211 static @Nonnull String buildOnlineCreateWithTryCatchFallback(String bareCreateSql) { 212 // Some "Editions" of Sql Server do not support ONLINE. 213 // @format:off 214 return "BEGIN TRY -- try first online, without locking the table \n" 215 + " EXEC('" + bareCreateSql + " WITH (ONLINE = ON)');\n" 216 + "END TRY \n" 217 + "BEGIN CATCH -- for Editions of Sql Server that don't support ONLINE, run with table locks \n" 218 + bareCreateSql 219 + "; \n" 220 + "END CATCH;"; 221 // @format:on 222 } 223 224 @Nonnull 225 private String buildMSSqlNotNullWhereClause() { 226 String mssqlWhereClause = ""; 227 if (myNullableColumns == null || myNullableColumns.isEmpty()) { 228 return mssqlWhereClause; 229 } 230 231 mssqlWhereClause = " WHERE ("; 232 mssqlWhereClause += myNullableColumns.stream() 233 .map(column -> column + " IS NOT NULL ") 234 .collect(Collectors.joining("AND")); 235 mssqlWhereClause += ")"; 236 return mssqlWhereClause; 237 } 238 239 public void setColumns(String... theColumns) { 240 setColumns(Arrays.asList(theColumns)); 241 } 242 243 public void setNullableColumns(String... theColumns) { 244 setNullableColumns(Arrays.asList(theColumns)); 245 } 246 247 public void setIncludeColumns(String... theIncludeColumns) { 248 setIncludeColumns(Arrays.asList(theIncludeColumns)); 249 } 250 251 private void setIncludeColumns(List<String> theIncludeColumns) { 252 Objects.requireNonNull(theIncludeColumns); 253 myIncludeColumns = theIncludeColumns; 254 } 255 256 /** 257 * Add Index without locking the table. 258 */ 259 public void setOnline(boolean theFlag) { 260 myOnline = theFlag; 261 } 262 263 @Override 264 protected void generateEquals(EqualsBuilder theBuilder, BaseTask theOtherObject) { 265 super.generateEquals(theBuilder, theOtherObject); 266 267 AddIndexTask otherObject = (AddIndexTask) theOtherObject; 268 theBuilder.append(myIndexName, otherObject.myIndexName); 269 theBuilder.append(myColumns, otherObject.myColumns); 270 theBuilder.append(myUnique, otherObject.myUnique); 271 theBuilder.append(myIncludeColumns, otherObject.myIncludeColumns); 272 theBuilder.append(myOnline, otherObject.myOnline); 273 } 274 275 @Override 276 protected void generateHashCode(HashCodeBuilder theBuilder) { 277 super.generateHashCode(theBuilder); 278 theBuilder.append(myIndexName); 279 theBuilder.append(myColumns); 280 theBuilder.append(myUnique); 281 theBuilder.append(myOnline); 282 } 283 284 public void setMetadataSource(MetadataSource theMetadataSource) { 285 myMetadataSource = theMetadataSource; 286 } 287}