Skip to content

SQL Server Migration 2 - #990

Open
labkey-bpatel wants to merge 42 commits into
release26.3-SNAPSHOTfrom
26.3_fb_sql_server_migration_2
Open

SQL Server Migration 2#990
labkey-bpatel wants to merge 42 commits into
release26.3-SNAPSHOTfrom
26.3_fb_sql_server_migration_2

Conversation

@labkey-bpatel

@labkey-bpatel labkey-bpatel commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Rationale

Part 2 of the SQL Server to PostgreSQL migration, continuing from #955. Where #955 covered SQL syntax portability, this PR addresses collation: SQL Server compares text case- and whitespace-insensitively, so source data carrying variants — animal IDs as 4x0133 and 4X0133, units as mg and MG, lookup values differing only in capitalisation — was being collapsed silently at DISTINCT, GROUP BY, UNION and PIVOT time. PostgreSQL compares exactly. Each affected site is now canonicalised in the query, or normalised once at migration time where that repairs more consumers at once. Results were reconciled query by query against the SQL Server source; one report's value legitimately changes, noted below.

Related Pull Requests

Changes

Grouped by why the two databases disagreed. Each file carries a comment explaining its own case.

Collation — SQL Server compares text case- and whitespace-insensitively and these sites relied on it, at GROUP BY, DISTINCT, UNION dedup, PIVOT value lists and string filters. MhcDataPivot, AdmitComplaintList, BehaviorAbnormalSummary, Paraasitology, proceduresPerYear, both ReportTcruziSummaryAll copies, the Parasitology / culture / misc / surveillance pivots and their inner queries, demographicsMostRecentTBDate and demographicsMostRecentPhysicalDate (an 'OKAY' branch among 'Okay' siblings put 30 infant baboons on the overdue report), DeletedLookups, DeletedLookupSets, R24WeightsDeletedSrc, DemographicsHL7DeleteSource (the last two returned zero rows on Postgres), and migration-time normalisation in SNPRC_EHRModule for GUID columns and two case-sensitive natural-key FKs.

Type coercion — the engines convert types differently, or refuse to. animalDemographics (CAST(x AS DECIMAL) is (18,0) on SQL Server, unspecified scale on Postgres), demographicsMostRecentWeight (rounding routed through NUMERIC rather than double), the two clinical-history data sources (timestamps read via getTimestamp() instead of parsing the driver's string rendering), and the PostgreSQL bootstrap (HL7_IMPORT_LOG.TID becomes a BIGINT identity — Postgres identity columns cannot be NUMERIC).

Dialect semantics — the same named function implemented differently. validateFinalWeights, an SNPRC override: timestampdiff counts calendar-date crossings on SQL Server and elapsed 24-hour periods on Postgres, which moved 31 animals across a >= 7 filter.

Neither — bugs the comparison exposed. culturePivotInner's two UNION legs listed their columns in opposite order, so panel and test name were transposed for every HL7-sourced row; colonyUsageQuery pivoted on a gender label that resolves to Male on Postgres and M on SQL Server, and referenced pivot columns with casing its subquery alias did not match (quoted identifiers are case-sensitive on Postgres); AnimalsHierarchyController filters visible with a boolean rather than 1; SNPRC_schedulerContainerListener runs its cleanup in a transaction; a missing statement terminator in is_numeric.

Scoped deliberately. lab_test_units is an SNPRC override rather than a change to the shared ehr query, which is an FK target consumed by WNPRC_EHR — uppercasing its values should not reach other centres. demographicsMostRecentWeight and validateFinalWeights are overrides for the same reason.

One report value changes. ReportTcruziSummaryAll now keys its UNION dedup on TestId rather than the test name, because labwork_panels holds 850 as T. CRUZI AB and 965 as T. Cruzi AB — two codes distinguishable only by capitalisation. Animal 28215 goes 5 → 6 and the total 257 → 258; Postgres's existing values are unchanged. Assumes the two codes are genuinely different assays — still an open question for SNPRC.

Tests. Shared setup extracted into SnprcSetupHelper; SNPRC_EHRTest and SNPRC_schedulerTest updated for PostgreSQL; sample-data Status values normalised.

Tasks 📍

  • Test Automation - fix failures
  • Code Review
  • Code Review changes
  • TC verify and merge

labkey-bpatel and others added 30 commits May 6, 2026 22:42
… and fix upgrade script. Fix query errors during Folder import.
…ries portable to Postgres (also fixes test failures)
SNPRC_schedulerTest now enables SNPRC_EHR and routes through the helper for SND/EHR domain init, SND category and package seeding, animal group tables, and the Core Facilities/Genetics subfolder plus assay design uploads; moves populateEHRTables() before importStudy() to match SNPRC_EHRTest's populateHardTableRecords-before-import ordering; and turns on import-time query validation.

Guard BeginPage.beginAt against a missing modal-backdrop element so it stops printing a spurious NoSuchElementException stack trace on every navigation.
…UIDs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The existing snprc_ehr schema handler was leaving GUID FK columns on study datasets (e.g. assay_labworkResults.serviceTestId) uppercase after migration, breaking their FKs to lowercase-canonicalized entityid targets like labwork_panels.ObjectId on Postgres. Extend registerMigrationHandlers with schema contributors for studydataset, ehr, and ehr_lookups so GuidMapperColumn covers those tables too.

Also add LookupSetNameCaseMapperColumn for ehr_lookups.lookups: SS's case-insensitive collation had let set_name values drift out of case with lookup_sets.setname (camelCase 'accessionCode' vs PascalCase 'AccessionCode'), which broke the framework-generated filter queries on Postgres. COALESCE-JOIN against lookup_sets at migration time normalizes to the canonical case.

Dedup key switched from table.getSelectName() to composite '<schema>.<table>' so bare-name collisions across the new schemas can't silently skip a table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DISTINCT + GROUP BY on the free-text admitcomplaint column returned different counts on Postgres than SQL Server because SS's case-insensitive collation collapsed variants like 'Trauma' vs 'trauma' or trailing-whitespace copies into single groups while PG kept them apart, dropping some groups below the HAVING count > 10 threshold. Wrap admitcomplaint with UPPER(LTRIM(RTRIM(...))) in both SELECT and GROUP BY so both databases return the same set (45 rows).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Source has case-variant animal IDs (e.g. 4x0133 vs 4X0133) that SS's case-insensitive collation grouped together in the outer GROUP BY; Postgres is case- and whitespace-sensitive so they split into separate output rows, producing 4512 rows on PG vs 4511 on SS. Wrap bn.Id with UPPER(LTRIM(RTRIM(...))) in the SELECT, GROUP BY, and ORDER BY so both databases return the same 4511 rows. The Behavior and BehaviorDescription columns don't have this variance, so those stay unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two overlapping causes of the +158 row discrepancy PG vs SS. First, case/whitespace variants on Id, Haplotype, Ocid, and DataFileSource were splitting groups that SS's collation collapsed — wrap the four columns with UPPER(LTRIM(RTRIM(...))) in the inner subquery so both databases see the same canonical form at GROUP BY and PIVOT time.

Second, SS's default cast(datetime as varchar) is minute-precision while PG's preserves microseconds, so identical-looking timestamps stayed distinct on PG and split groups. Aggregate b.modified via MAX() in the outer SELECT and remove it from GROUP BY; grouping is no longer sensitive to sub-second drift, and the output still shows the latest modification per pivot row. Add a DateTime formatString on modified in the query metadata for consistent display.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
lt.ServiceId.ServiceName has case/whitespace variants in the source data (e.g. 'Ova & Parasites' vs 'OVA & PARASITES'). SS's case-insensitive collation matched both against the NOT IN list; Postgres is case- and whitespace-sensitive so 2059 variants slipped past the exclusion, giving 2077 rows on PG vs the intended 18. Wrap the column with UPPER(RTRIM(LTRIM(...))) so both databases produce 18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The report's WHERE clauses filter on b.qualresult, b.serviceTestId.testName, and b.serviceTestid.ServiceId.Dataset. SS's case-insensitive collation matched them regardless of source-data casing; Postgres is case-sensitive and drops rows where the case doesn't line up exactly. Wrap each filter column and the CASE b.qualresult expression with UPPER(LTRIM(RTRIM(...))).

Also wrap b.id (animal ID) in the inner SELECTs so case-variant animal IDs collapse in the outer GROUP BY the same way SS does. Same fix applied to both the study and snprc_ehr copies of this query since they carry the same body.

Correctness against the base data still depends on the migration handler widening that lands the missing assay_labworkResults rows on PG; this commit only fixes the query-side case-sensitivity concerns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
safeAppendTimeFromDate() and safeAppendDateAndTime() read the column with rs.getString() and parsed it against a fixed "yyyy-MM-dd HH:mm:ss[.f]" pattern. The string form is driver-specific, so that pattern has to track every driver's rendering -- it was recently widened to make fractional seconds optional, and a timestamptz column through pgjdbc would still fail on the trailing offset. Read the value as a Timestamp and format from LocalDateTime instead, which removes the dependency on the driver's string form entirely. Also hoists the output DateTimeFormatter into a constant; it was being reallocated on every rendered row.
The SNPRC source data has case/whitespace variants of the same unit (e.g. 'mg' vs 'MG'). SQL Server's case-insensitive collation collapsed them on DISTINCT; Postgres returns them as separate rows. Canonicalize with UPPER(LTRIM(RTRIM(...))) here rather than in the shared ehr module: lab_test_units is an FK target, not just a display list -- its query.xml declares units as the key field, and it is referenced as <fkTable>lab_test_units</fkTable> by ehr's study/chemistryResults, study/hematologyResults and study/urinalysisResults, and directly by WNPRC_EHR's datasets_metadata.xml. Uppercasing changes the set of key values the lookup offers, so it should not be imposed on the other centers.
…fb_sql_server_migration_2

# Conflicts:
#	snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRModule.java
#	snprc_ehr/src/org/labkey/snprc_ehr/history/DefaultTreatmentOrdersDataSource.java
#	snprc_ehr/src/org/labkey/snprc_ehr/history/LabworkDataSource.java
Postgres compares text case-sensitively where SQL Server's default collation does not, so several SNPRC EHR queries that were correct by accident on SQL Server return wrong results after the migration.

Normalize panel, test and result names with UPPER(RTRIM(LTRIM(...))) in the labwork pivot queries for Culture, Misc Tests and Surveillance. The PIVOT IN list is built from snprc_ehr.labwork_panels while the pivoted rows come from study.labworkResults and the HL7 OBR/OBX tables; on SQL Server those matched regardless of case and padding, but on Postgres they have to match exactly or the pivot columns come back empty. Both sides are now normalized the same way.

Correct the 'OKAY' literal to 'Okay' in demographicsMostRecentPhysicalDate and demographicsMostRecentTBDate. study.overdueTb filters on PhyStatus/TbStatus <> 'Okay', so on Postgres the mis-cased literal put infant baboons on the overdue reports; SQL Server's case-insensitive comparison hid the typo.

Point the admin 'Enter Data' link on the SNPRC begin page at ehr-enterData.view instead of the legacy/Ext3 ehr-dataEntry.view
DeletedLookups and DeletedLookupSets pull objectid out of the stored audit record by searching oldrecordmap for the text 'objectid=' and taking the following 36 characters. The literal was lowercase and locate() is case-sensitive on Postgres, so the search failed on every row, while SQL Server's case-insensitive collation matched regardless of how the key was stored.

The failure was silent rather than loud. With locate() returning 0 the substring started at a fixed offset instead of stopping, so all 130 rows came back as 36-character fragments of unrelated text filling a GUID-shaped column, e.g. 'tId=2177&value=ddfsdfd&displayable=t' in place of '02c68113-c5a4-103b-b46b-9caba13b78b2'. Anything joining on objectid matched nothing, with no error and no null to signal it.

Search a lower-cased copy of oldrecordmap so any casing is found. lower() preserves length, so the offset arithmetic stays valid for substring() on the original and the extracted value keeps its stored casing. Hard-coding the casing instead would not be safe: the audit map's keys come from the row map the update service supplied (see AbstractAuditTypeProvider.encodeForDataMap), so they follow the calling code's naming rather than the declared column, and these are historical rows written by several code paths.

Verified on DeletedLookups against a Postgres capture: objectid now returns GUIDs matching SQL Server. DeletedLookupSets carries the identical change but was not separately verified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants