From 4c576cac92a813ba1099f8cf9f78b4d970a42b62 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 14 May 2026 16:19:53 -0700 Subject: [PATCH 1/5] Add SNPRC_EHR upgrade code to fix specimen StorageColumnName mismatches Bumps schema version to 26.001 and registers SNPRC_EHRUpgradeCode, which verifies each PropertyDescriptor in the specimen storage schema points at an actual provisioned column and resets StorageColumnName to Name when it doesn't. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/labkey/snprc_ehr/SNPRC_EHRModule.java | 10 +- .../snprc_ehr/SNPRC_EHRUpgradeCode.java | 121 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java diff --git a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRModule.java b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRModule.java index 7f22b236a..d566d66c4 100644 --- a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRModule.java +++ b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRModule.java @@ -21,6 +21,7 @@ import org.labkey.api.data.Container; import org.labkey.api.data.DbSchema; import org.labkey.api.data.ObjectFactory; +import org.labkey.api.data.UpgradeCode; import org.labkey.api.ehr.EHRService; import org.labkey.api.ehr.dataentry.DefaultDataEntryFormFactory; import org.labkey.api.ehr.history.DefaultArrivalDataSource; @@ -119,7 +120,14 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 26.000; + return 26.001; + } + + @Nullable + @Override + public UpgradeCode getUpgradeCode() + { + return new SNPRC_EHRUpgradeCode(); } @Override diff --git a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java new file mode 100644 index 000000000..675515406 --- /dev/null +++ b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.snprc_ehr; + +import org.apache.logging.log4j.Logger; +import org.labkey.api.data.DbSchema; +import org.labkey.api.data.DbSchemaType; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.DbScope.Transaction; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SchemaTableInfo; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.UpgradeCode; +import org.labkey.api.exp.OntologyManager; +import org.labkey.api.module.ModuleContext; +import org.labkey.api.specimen.model.SpecimenTablesProvider; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.logging.LogHelper; + +import java.util.List; + +public class SNPRC_EHRUpgradeCode implements UpgradeCode +{ + private static final Logger LOG = LogHelper.getLogger(SNPRC_EHRUpgradeCode.class, "SNPRC_EHR upgrade status"); + + /** + * Called from snprc_ehr-26.000-26.001.sql + * + * For every PropertyDescriptor in the specimen storage schema, verify that its StorageColumnName + * matches an actual column in the provisioned table. If not, update the PropertyDescriptor's + * StorageColumnName to match its Name. + */ + @SuppressWarnings("unused") + public static void fixSpecimenStorageColumnNames(ModuleContext context) + { + if (context.isNewInstall()) + return; + + TableInfo tinfoDomainDescriptor = OntologyManager.getTinfoDomainDescriptor(); + TableInfo tinfoPropertyDomain = OntologyManager.getTinfoPropertyDomain(); + TableInfo tinfoPropertyDescriptor = OntologyManager.getTinfoPropertyDescriptor(); + + DbScope scope = tinfoPropertyDescriptor.getSchema().getScope(); + DbSchema specimenSchema = DbSchema.get(SpecimenTablesProvider.SCHEMA_NAME, DbSchemaType.Provisioned); + + SQLFragment sql = new SQLFragment("SELECT px.PropertyId, dd.StorageSchemaName, dd.StorageTableName, px.StorageColumnName, px.Name FROM ") + .append(tinfoDomainDescriptor, "dd") + .append(" INNER JOIN ") + .append(tinfoPropertyDomain, "pd") + .append(" ON dd.DomainId = pd.DomainId INNER JOIN ") + .append(tinfoPropertyDescriptor, "px") + .append(" ON pd.PropertyId = px.PropertyId ") + .append("WHERE dd.StorageSchemaName = ?").add(SpecimenTablesProvider.SCHEMA_NAME) + .append(" AND dd.StorageTableName IS NOT NULL AND px.StorageColumnName IS NOT NULL"); + + List properties = new SqlSelector(scope, sql).getArrayList(PropertyRow.class); + LOG.info("Checking {} PropertyDescriptors in specimen storage schema '{}'", properties.size(), SpecimenTablesProvider.SCHEMA_NAME); + + int updated = 0; + int skipped = 0; + try (Transaction tx = scope.ensureTransaction()) + { + for (PropertyRow row : properties) + { + SchemaTableInfo provisioned = specimenSchema.getTable(row.storageTableName()); + if (provisioned == null) + { + LOG.warn("Provisioned table '{}.{}' does not exist; skipping property '{}'", + row.storageSchemaName(), row.storageTableName(), row.name()); + skipped++; + continue; + } + + if (provisioned.getColumn(row.storageColumnName()) != null) + continue; + + if (row.name().equalsIgnoreCase(row.storageColumnName())) + { + LOG.warn("Property '{}' in '{}.{}' has StorageColumnName '{}' that does not exist, but Name already matches; nothing to update", + row.name(), row.storageSchemaName(), row.storageTableName(), row.storageColumnName()); + skipped++; + continue; + } + + if (provisioned.getColumn(row.name()) == null) + { + LOG.warn("Property '{}' in '{}.{}': neither StorageColumnName '{}' nor Name '{}' exists in provisioned table; skipping", + row.name(), row.storageSchemaName(), row.storageTableName(), row.storageColumnName(), row.name()); + skipped++; + continue; + } + + LOG.info("Updating StorageColumnName from '{}' to '{}' for property '{}' in table '{}.{}'", + row.storageColumnName(), row.name(), row.name(), row.storageSchemaName(), row.storageTableName()); + Table.update(null, tinfoPropertyDescriptor, PageFlowUtil.map("StorageColumnName", row.name()), row.propertyId()); + updated++; + } + tx.commit(); + } + + LOG.info("Specimen StorageColumnName fix complete: {} updated, {} skipped, {} already correct", + updated, skipped, properties.size() - updated - skipped); + } + + public record PropertyRow(int propertyId, String storageSchemaName, String storageTableName, String storageColumnName, String name) {} +} From 45880b65403a198df59ee0feeab57ca4066db5bc Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 14 May 2026 16:26:07 -0700 Subject: [PATCH 2/5] Add SQL Server upgrade script invoking fixSpecimenStorageColumnNames Completes the 26.000 -> 26.001 schema bump by hooking the upgrade SQL to the Java method added in 4c576cac. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../schemas/dbscripts/sqlserver/snprc_ehr-26.000-26.001.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 snprc_ehr/resources/schemas/dbscripts/sqlserver/snprc_ehr-26.000-26.001.sql diff --git a/snprc_ehr/resources/schemas/dbscripts/sqlserver/snprc_ehr-26.000-26.001.sql b/snprc_ehr/resources/schemas/dbscripts/sqlserver/snprc_ehr-26.000-26.001.sql new file mode 100644 index 000000000..143a3a56e --- /dev/null +++ b/snprc_ehr/resources/schemas/dbscripts/sqlserver/snprc_ehr-26.000-26.001.sql @@ -0,0 +1,2 @@ +EXEC core.executeJavaUpgradeCode 'fixSpecimenStorageColumnNames'; + From 13b07704d1a009f2ab1604c0e08d6e782a9f35ff Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 14 May 2026 16:29:47 -0700 Subject: [PATCH 3/5] Add platform:study apiJar dependency to snprc_ehr Needed by SNPRC_EHRUpgradeCode for SpecimenTablesProvider. Co-Authored-By: Claude Opus 4.7 (1M context) --- snprc_ehr/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/snprc_ehr/build.gradle b/snprc_ehr/build.gradle index bb7bfee92..12c4d3087 100644 --- a/snprc_ehr/build.gradle +++ b/snprc_ehr/build.gradle @@ -3,6 +3,7 @@ import org.labkey.gradle.util.ExternalDependency dependencies { + BuildUtils.addLabKeyDependency(project: project, config: "implementation", depProjectPath: ":server:modules:platform:study", depProjectConfig: "apiJarFile") BuildUtils.addLabKeyDependency(project: project, config: "implementation", depProjectPath: ":server:modules:ehrModules:ehr", depProjectConfig: "apiJarFile") BuildUtils.addLabKeyDependency(project: project, config: "implementation", depProjectPath: ":server:modules:snprcEHRModules:snd", depProjectConfig: "apiJarFile") BuildUtils.addLabKeyDependency(project: project, config: "implementation", depProjectPath: ":server:modules:LabDevKitModules:LDK", depProjectConfig: "apiJarFile") From d5d85aec47c9ee9697dad8cee438cdd5236a5e5b Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 18 May 2026 15:57:14 -0700 Subject: [PATCH 4/5] Update SNPRC_EHRUpgradeCode.java --- .../snprc_ehr/SNPRC_EHRUpgradeCode.java | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java index 675515406..a8932171a 100644 --- a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java +++ b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java @@ -16,6 +16,7 @@ package org.labkey.snprc_ehr; import org.apache.logging.log4j.Logger; +import org.labkey.api.data.ColumnInfo; import org.labkey.api.data.DbSchema; import org.labkey.api.data.DbSchemaType; import org.labkey.api.data.DbScope; @@ -42,8 +43,9 @@ public class SNPRC_EHRUpgradeCode implements UpgradeCode * Called from snprc_ehr-26.000-26.001.sql * * For every PropertyDescriptor in the specimen storage schema, verify that its StorageColumnName - * matches an actual column in the provisioned table. If not, update the PropertyDescriptor's - * StorageColumnName to match its Name. + * matches an actual column in the provisioned table. If not, and the descriptor's Name resolves + * to a real column, update StorageColumnName to that column's physical (DB-metadata) name so the + * value will still match after migration to a case-sensitive database. */ @SuppressWarnings("unused") public static void fixSpecimenStorageColumnNames(ModuleContext context) @@ -73,6 +75,7 @@ public static void fixSpecimenStorageColumnNames(ModuleContext context) int updated = 0; int skipped = 0; + int alreadyCorrect = 0; try (Transaction tx = scope.ensureTransaction()) { for (PropertyRow row : properties) @@ -87,17 +90,15 @@ public static void fixSpecimenStorageColumnNames(ModuleContext context) } if (provisioned.getColumn(row.storageColumnName()) != null) - continue; - - if (row.name().equalsIgnoreCase(row.storageColumnName())) { - LOG.warn("Property '{}' in '{}.{}' has StorageColumnName '{}' that does not exist, but Name already matches; nothing to update", + LOG.debug("Property '{}' in '{}.{}': StorageColumnName '{}' already resolves; nothing to update", row.name(), row.storageSchemaName(), row.storageTableName(), row.storageColumnName()); - skipped++; + alreadyCorrect++; continue; } - if (provisioned.getColumn(row.name()) == null) + ColumnInfo byName = provisioned.getColumn(row.name()); + if (byName == null) { LOG.warn("Property '{}' in '{}.{}': neither StorageColumnName '{}' nor Name '{}' exists in provisioned table; skipping", row.name(), row.storageSchemaName(), row.storageTableName(), row.storageColumnName(), row.name()); @@ -105,16 +106,17 @@ public static void fixSpecimenStorageColumnNames(ModuleContext context) continue; } + String newStorageColumnName = byName.getMetaDataIdentifier().getId(); LOG.info("Updating StorageColumnName from '{}' to '{}' for property '{}' in table '{}.{}'", - row.storageColumnName(), row.name(), row.name(), row.storageSchemaName(), row.storageTableName()); - Table.update(null, tinfoPropertyDescriptor, PageFlowUtil.map("StorageColumnName", row.name()), row.propertyId()); + row.storageColumnName(), newStorageColumnName, row.name(), row.storageSchemaName(), row.storageTableName()); + Table.update(null, tinfoPropertyDescriptor, PageFlowUtil.map("StorageColumnName", newStorageColumnName), row.propertyId()); updated++; } tx.commit(); } LOG.info("Specimen StorageColumnName fix complete: {} updated, {} skipped, {} already correct", - updated, skipped, properties.size() - updated - skipped); + updated, skipped, alreadyCorrect); } public record PropertyRow(int propertyId, String storageSchemaName, String storageTableName, String storageColumnName, String name) {} From 5d25d412b86fc46619c1b11d65ec1d15f3e5e2d4 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 20 May 2026 07:31:31 -0700 Subject: [PATCH 5/5] Compare StorageColumnName case-sensitively and clear ontology caches The existing resolution check used TableInfo.getColumn() which is case-insensitive (CaseInsensitiveHashMap), so a StorageColumnName that differed from the physical column only in case was treated as already correct on the SQL Server source. After migration to a case-sensitive destination database those rows would still fail, defeating the purpose of the pre-migration fix. Resolve the column by StorageColumnName first; consider it correct only when row.storageColumnName().equals(byStorage.getMetaDataIdentifier().getId()). Otherwise rewrite to the physical metadata identifier of either the StorageColumnName-resolved column or, failing that, the column resolved by Name. This catches both entirely-broken StorageColumnName values and case-only mismatches. Also call OntologyManager.clearCaches() after committing the updates so any PropertyDescriptors populated into PROP_DESCRIPTOR_CACHE earlier in startup don't retain pre-update StorageColumnName values. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../snprc_ehr/SNPRC_EHRUpgradeCode.java | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java index a8932171a..dccefd73c 100644 --- a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java +++ b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRUpgradeCode.java @@ -43,9 +43,10 @@ public class SNPRC_EHRUpgradeCode implements UpgradeCode * Called from snprc_ehr-26.000-26.001.sql * * For every PropertyDescriptor in the specimen storage schema, verify that its StorageColumnName - * matches an actual column in the provisioned table. If not, and the descriptor's Name resolves - * to a real column, update StorageColumnName to that column's physical (DB-metadata) name so the - * value will still match after migration to a case-sensitive database. + * exactly matches (case-sensitively) the physical metadata identifier of a column on the provisioned + * table. If not, rewrite it to the physical identifier of the column resolved either by the existing + * (case-insensitive) StorageColumnName lookup or, failing that, by the descriptor's Name. This makes + * the value still match after migration to a case-sensitive database. */ @SuppressWarnings("unused") public static void fixSpecimenStorageColumnNames(ModuleContext context) @@ -89,16 +90,20 @@ public static void fixSpecimenStorageColumnNames(ModuleContext context) continue; } - if (provisioned.getColumn(row.storageColumnName()) != null) + // getColumn() is case-insensitive, so a case-mismatched StorageColumnName still resolves + // on SQL Server but will break after migration to a case-sensitive DB. Compare to the + // physical metadata identifier to decide whether a rewrite is needed. + ColumnInfo byStorage = provisioned.getColumn(row.storageColumnName()); + if (byStorage != null && row.storageColumnName().equals(byStorage.getMetaDataIdentifier().getId())) { - LOG.debug("Property '{}' in '{}.{}': StorageColumnName '{}' already resolves; nothing to update", + LOG.debug("Property '{}' in '{}.{}': StorageColumnName '{}' already matches physical column case; nothing to update", row.name(), row.storageSchemaName(), row.storageTableName(), row.storageColumnName()); alreadyCorrect++; continue; } - ColumnInfo byName = provisioned.getColumn(row.name()); - if (byName == null) + ColumnInfo target = byStorage != null ? byStorage : provisioned.getColumn(row.name()); + if (target == null) { LOG.warn("Property '{}' in '{}.{}': neither StorageColumnName '{}' nor Name '{}' exists in provisioned table; skipping", row.name(), row.storageSchemaName(), row.storageTableName(), row.storageColumnName(), row.name()); @@ -106,7 +111,7 @@ public static void fixSpecimenStorageColumnNames(ModuleContext context) continue; } - String newStorageColumnName = byName.getMetaDataIdentifier().getId(); + String newStorageColumnName = target.getMetaDataIdentifier().getId(); LOG.info("Updating StorageColumnName from '{}' to '{}' for property '{}' in table '{}.{}'", row.storageColumnName(), newStorageColumnName, row.name(), row.storageSchemaName(), row.storageTableName()); Table.update(null, tinfoPropertyDescriptor, PageFlowUtil.map("StorageColumnName", newStorageColumnName), row.propertyId()); @@ -115,6 +120,10 @@ public static void fixSpecimenStorageColumnNames(ModuleContext context) tx.commit(); } + // Drop any cached PropertyDescriptors that may have been populated earlier in startup with the + // pre-update StorageColumnName. + OntologyManager.clearCaches(); + LOG.info("Specimen StorageColumnName fix complete: {} updated, {} skipped, {} already correct", updated, skipped, alreadyCorrect); }