Enhance migration for MSSQL to change the collation for HFJ_RESOURCE.FHIR_ID to case sensitive (#6135)
* MSSQL: Migrate HFJ_RESOURCE.FHIR_ID to new collation: SQL_Latin1_General_CP1_CS_AS * Spotless. * Enhance test. Fix case in ResourceSearchView to defend against future migration to case insensitive collation. * Remove TODOs. Add comment to ResourceSearchView explaining why all columns are uppercase. Changelog. * Update hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/6146-mssql-hfj-resource-fhir-id-colllation.yaml Code reviewer suggestion Co-authored-by: Michael Buckley <michaelabuckley@gmail.com> * Code review fixes: Make changes conditional on the collation including _CI_, otherwise, leave it alone. --------- Co-authored-by: Michael Buckley <michaelabuckley@gmail.com>
This commit is contained in:
parent
33b766c658
commit
a70d28acae
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
type: fix
|
||||
issue: 6146
|
||||
jira: SMILE-8191
|
||||
title: "Previously, on MSSQL, two resources with IDs that are identical except for case
|
||||
(ex: Patient1 vs. patient1) would be considered to have the same ID because the database collation is
|
||||
case insensitive (SQL_Latin1_General_CP1_CI_AS). Among other things, this would manifest
|
||||
itself when trying to delete and re-create one of the resources.
|
||||
This has been fixed with a migration step that makes the collation on the resource ID case sensitive
|
||||
(SQL_Latin1_General_CP1_CS_AS)."
|
|
@ -46,27 +46,29 @@ import java.util.Date;
|
|||
@SuppressWarnings("SqlDialectInspection")
|
||||
@Entity
|
||||
@Immutable
|
||||
@Subselect("SELECT h.pid as pid, "
|
||||
+ " r.res_id as res_id, "
|
||||
+ " h.res_type as res_type, "
|
||||
+ " h.res_version as res_version, "
|
||||
// Ideally, all tables and columns should be in UPPERCASE if we ever choose to use a case-sensitive collation for MSSQL
|
||||
// and there's a risk that queries on lowercase database objects fail.
|
||||
@Subselect("SELECT h.PID as PID, "
|
||||
+ " r.RES_ID as RES_ID, "
|
||||
+ " h.RES_TYPE as RES_TYPE, "
|
||||
+ " h.RES_VERSION as RES_VERSION, "
|
||||
// FHIR version
|
||||
+ " h.res_ver as res_ver, "
|
||||
+ " h.RES_VER as RES_VER, "
|
||||
// resource version
|
||||
+ " h.has_tags as has_tags, "
|
||||
+ " h.res_deleted_at as res_deleted_at, "
|
||||
+ " h.res_published as res_published, "
|
||||
+ " h.res_updated as res_updated, "
|
||||
+ " h.res_text as res_text, "
|
||||
+ " h.res_text_vc as res_text_vc, "
|
||||
+ " h.res_encoding as res_encoding, "
|
||||
+ " h.HAS_TAGS as HAS_TAGS, "
|
||||
+ " h.RES_DELETED_AT as RES_DELETED_AT, "
|
||||
+ " h.RES_PUBLISHED as RES_PUBLISHED, "
|
||||
+ " h.RES_UPDATED as RES_UPDATED, "
|
||||
+ " h.RES_TEXT as RES_TEXT, "
|
||||
+ " h.RES_TEXT_VC as RES_TEXT_VC, "
|
||||
+ " h.RES_ENCODING as RES_ENCODING, "
|
||||
+ " h.PARTITION_ID as PARTITION_ID, "
|
||||
+ " p.SOURCE_URI as PROV_SOURCE_URI,"
|
||||
+ " p.REQUEST_ID as PROV_REQUEST_ID,"
|
||||
+ " r.fhir_id as FHIR_ID "
|
||||
+ " r.FHIR_ID as FHIR_ID "
|
||||
+ "FROM HFJ_RESOURCE r "
|
||||
+ " INNER JOIN HFJ_RES_VER h ON r.res_id = h.res_id and r.res_ver = h.res_ver"
|
||||
+ " LEFT OUTER JOIN HFJ_RES_VER_PROV p ON p.res_ver_pid = h.pid ")
|
||||
+ " INNER JOIN HFJ_RES_VER h ON r.RES_ID = h.RES_ID and r.RES_VER = h.RES_VER"
|
||||
+ " LEFT OUTER JOIN HFJ_RES_VER_PROV p ON p.RES_VER_PID = h.PID ")
|
||||
public class ResourceSearchView implements IBaseResourceEntity, Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
|
|
@ -49,6 +49,7 @@ import ca.uhn.fhir.jpa.model.entity.StorageSettings;
|
|||
import ca.uhn.fhir.util.ClasspathUtil;
|
||||
import ca.uhn.fhir.util.VersionEnum;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.intellij.lang.annotations.Language;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
|
@ -473,6 +474,54 @@ public class HapiFhirJpaMigrationTasks extends BaseMigrationTasks<VersionEnum> {
|
|||
.modifyColumn("20240722.1", Search.SEARCH_UUID)
|
||||
.nullable()
|
||||
.withType(ColumnTypeEnum.STRING, 48);
|
||||
|
||||
{
|
||||
final Builder.BuilderWithTableName hfjResource = version.onTable("HFJ_RESOURCE");
|
||||
|
||||
@Language(("SQL"))
|
||||
final String onlyIfSql = "SELECT CASE CHARINDEX('_CI_', COLLATION_NAME) WHEN 0 THEN 0 ELSE 1 END "
|
||||
+ "FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
+ "WHERE TABLE_SCHEMA = SCHEMA_NAME() "
|
||||
+ "AND TABLE_NAME = 'HFJ_RESOURCE' "
|
||||
+ "AND COLUMN_NAME = 'FHIR_ID' ";
|
||||
final String onlyfIReason =
|
||||
"Skipping change to HFJ_RESOURCE.FHIR_ID collation to SQL_Latin1_General_CP1_CS_AS because it is already using it";
|
||||
|
||||
hfjResource
|
||||
.dropIndex("20240724.10", "IDX_RES_FHIR_ID")
|
||||
.onlyAppliesToPlatforms(DriverTypeEnum.MSSQL_2012)
|
||||
.onlyIf(onlyIfSql, onlyfIReason);
|
||||
|
||||
hfjResource
|
||||
.dropIndex("20240724.20", "IDX_RES_TYPE_FHIR_ID")
|
||||
.onlyAppliesToPlatforms(DriverTypeEnum.MSSQL_2012)
|
||||
.onlyIf(onlyIfSql, onlyfIReason);
|
||||
|
||||
version.executeRawSql(
|
||||
"20240724.30",
|
||||
"ALTER TABLE HFJ_RESOURCE ALTER COLUMN FHIR_ID varchar(64) COLLATE SQL_Latin1_General_CP1_CS_AS")
|
||||
.onlyAppliesToPlatforms(DriverTypeEnum.MSSQL_2012)
|
||||
.onlyIf(onlyIfSql, onlyfIReason);
|
||||
|
||||
hfjResource
|
||||
.addIndex("20240724.40", "IDX_RES_FHIR_ID")
|
||||
.unique(false)
|
||||
.online(true)
|
||||
.withColumns("FHIR_ID")
|
||||
.onlyAppliesToPlatforms(DriverTypeEnum.MSSQL_2012)
|
||||
.onlyIf(onlyIfSql, onlyfIReason);
|
||||
|
||||
hfjResource
|
||||
.addIndex("20240724.50", "IDX_RES_TYPE_FHIR_ID")
|
||||
.unique(true)
|
||||
.online(true)
|
||||
// include res_id and our deleted flag so we can satisfy Observation?_sort=_id from the index on
|
||||
// platforms that support it.
|
||||
.includeColumns("RES_ID, RES_DELETED_AT")
|
||||
.withColumns("RES_TYPE", "FHIR_ID")
|
||||
.onlyAppliesToPlatforms(DriverTypeEnum.MSSQL_2012)
|
||||
.onlyIf(onlyIfSql, onlyfIReason);
|
||||
}
|
||||
}
|
||||
|
||||
protected void init720() {
|
||||
|
|
|
@ -13,6 +13,7 @@ import ca.uhn.fhir.util.VersionEnum;
|
|||
import jakarta.annotation.Nonnull;
|
||||
import jakarta.annotation.Nullable;
|
||||
import org.apache.commons.dbcp2.BasicDataSource;
|
||||
import org.intellij.lang.annotations.Language;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
@ -25,7 +26,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
|
@ -68,6 +71,8 @@ public class HapiSchemaMigrationTest {
|
|||
private static final String COLUMN_VAL_VC = "VAL_VC";
|
||||
|
||||
private static final String NULL_PLACEHOLDER = "[NULL]";
|
||||
private static final String COLLATION_CASE_INSENSITIVE = "SQL_Latin1_General_CP1_CI_AS";
|
||||
private static final String COLLATION_CASE_SENSITIVE = "SQL_Latin1_General_CP1_CS_AS";
|
||||
|
||||
static {
|
||||
HapiSystemProperties.enableUnitTestMode();
|
||||
|
@ -127,6 +132,8 @@ public class HapiSchemaMigrationTest {
|
|||
verifyHfjResSearchUrlMigration(database, theDriverType);
|
||||
|
||||
verifyTrm_Concept_Desig(database, theDriverType);
|
||||
|
||||
verifyHfjResourceFhirIdCollation(database, theDriverType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -170,7 +177,7 @@ public class HapiSchemaMigrationTest {
|
|||
try (final Connection connection = theDatabase.getDataSource().getConnection()) {
|
||||
final DatabaseMetaData tableMetaData = connection.getMetaData();
|
||||
|
||||
final List<Map<String,String>> actualColumnResults = new ArrayList<>();
|
||||
final List<Map<String, String>> actualColumnResults = new ArrayList<>();
|
||||
try (final ResultSet columnsResultSet = tableMetaData.getColumns(null, null, TABLE_HFJ_RES_SEARCH_URL, null)) {
|
||||
while (columnsResultSet.next()) {
|
||||
final Map<String, String> columnMap = new HashMap<>();
|
||||
|
@ -183,7 +190,7 @@ public class HapiSchemaMigrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
final List<Map<String,String>> actualPrimaryKeyResults = new ArrayList<>();
|
||||
final List<Map<String, String>> actualPrimaryKeyResults = new ArrayList<>();
|
||||
|
||||
try (final ResultSet primaryKeyResultSet = tableMetaData.getPrimaryKeys(null, null, TABLE_HFJ_RES_SEARCH_URL)) {
|
||||
while (primaryKeyResultSet.next()) {
|
||||
|
@ -300,7 +307,7 @@ public class HapiSchemaMigrationTest {
|
|||
: Integer.toString(Types.VARCHAR);
|
||||
}
|
||||
|
||||
private void extractAndAddToMap(ResultSet theResultSet, Map<String,String> theMap, String theColumn) throws SQLException {
|
||||
private void extractAndAddToMap(ResultSet theResultSet, Map<String, String> theMap, String theColumn) throws SQLException {
|
||||
theMap.put(theColumn, Optional.ofNullable(theResultSet.getString(theColumn))
|
||||
.map(defaultValueNonNull -> defaultValueNonNull.equals("((-1))") ? "-1" : defaultValueNonNull) // MSSQL returns "((-1))" for default value
|
||||
.map(String::toUpperCase)
|
||||
|
@ -336,7 +343,6 @@ public class HapiSchemaMigrationTest {
|
|||
dataSource.setUsername("SA");
|
||||
dataSource.setPassword("SA");
|
||||
dataSource.start();
|
||||
|
||||
MigrationTaskList migrationTasks = new HapiFhirJpaMigrationTasks(Collections.emptySet()).getTaskList(VersionEnum.V6_0_0, VersionEnum.V6_4_0);
|
||||
HapiMigrationDao hapiMigrationDao = new HapiMigrationDao(dataSource, DriverTypeEnum.H2_EMBEDDED, HAPI_FHIR_MIGRATION_TABLENAME);
|
||||
HapiMigrationStorageSvc hapiMigrationStorageSvc = new HapiMigrationStorageSvc(hapiMigrationDao);
|
||||
|
@ -349,4 +355,64 @@ public class HapiSchemaMigrationTest {
|
|||
assertFalse(schemaMigrator.createMigrationTableIfRequired());
|
||||
|
||||
}
|
||||
|
||||
private void verifyHfjResourceFhirIdCollation(JpaEmbeddedDatabase database, DriverTypeEnum theDriverType) throws SQLException {
|
||||
if (DriverTypeEnum.MSSQL_2012 == theDriverType) { // Other databases are unaffected by this migration and are irrelevant
|
||||
try (final Connection connection = database.getDataSource().getConnection()) {
|
||||
@Language("SQL")
|
||||
final String databaseCollationSql = """
|
||||
SELECT collation_name
|
||||
FROM sys.databases
|
||||
WHERE name = 'master'
|
||||
""";
|
||||
|
||||
final Map<String, Object> databaseCollationRow = querySingleRow(connection, databaseCollationSql);
|
||||
assertThat(databaseCollationRow.get("collation_name")).isEqualTo(COLLATION_CASE_INSENSITIVE);
|
||||
|
||||
@Language("SQL")
|
||||
final String tableColumnSql = """
|
||||
SELECT c.collation_name
|
||||
FROM sys.columns c
|
||||
INNER JOIN sys.tables t on c.object_id = t.object_id
|
||||
INNER JOIN sys.schemas s on t.schema_id = s.schema_id
|
||||
INNER JOIN sys.databases d on s.principal_id = d.database_id
|
||||
where d.name = 'master'
|
||||
AND s.name = 'dbo'
|
||||
AND t.name = 'HFJ_RESOURCE'
|
||||
AND c.name = 'FHIR_ID';
|
||||
""";
|
||||
|
||||
final Map<String, Object> tableColumnCollationRow = querySingleRow(connection, tableColumnSql);
|
||||
assertThat(tableColumnCollationRow.get("collation_name")).isEqualTo(COLLATION_CASE_SENSITIVE);
|
||||
|
||||
// We have not changed the database collation, so we can reference the table and column names with the wrong
|
||||
// case and the query will work
|
||||
@Language("SQL")
|
||||
final String fhirIdSql = """
|
||||
SELECT fhir_id
|
||||
FROM hfj_resource
|
||||
WHERE fhir_id = '2029'
|
||||
""";
|
||||
|
||||
final Map<String, Object> fhirIdRow = querySingleRow(connection, fhirIdSql);
|
||||
assertThat(fhirIdRow.get("fhir_id")).isEqualTo("2029");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String,Object> querySingleRow(Connection connection, String theSql) throws SQLException {
|
||||
final Map<String, Object> row = new HashMap<>();
|
||||
try (final PreparedStatement preparedStatement = connection.prepareStatement(theSql)) {
|
||||
try (final ResultSet resultSet = preparedStatement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
final ResultSetMetaData resultSetMetadata = resultSet.getMetaData();
|
||||
for (int index = 1; index < resultSetMetadata.getColumnCount() +1; index++) {
|
||||
row.put(resultSetMetadata.getColumnName(index), resultSet.getObject(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue