Overview Phase 1 — Assessment Phase 2 — Schema Conversion Phase 3 — Data Migration Phase 4 — Validation Phase 5 — Performance Phase 6 — Documentation
Phase 02 — Schema Conversion

Converting PostgreSQL Schema to T-SQL

I converted the full healthcare_dba schema to T-SQL for SQL Server 2025 — five tables across two schemas, every key and index, and a complete rewrite of the one piece of custom logic in the database: a generic PL/pgSQL audit-logging trigger with no direct T-SQL equivalent.

T-SQL DDL Type Mapping Composite Keys JSON Triggers 8 Indexes 2 Schemas

What I Did in Phase 2

Scope

I created both schemas (cms, audit) and all 5 tables in T-SQL, including all 4 primary keys, the 1 foreign key, and all 8 secondary indexes — matching the source exactly. I decided to migrate cms.staging_raw as-is, keeping every column NVARCHAR to mirror the source's raw landing-table structure rather than tightening its types.

5
Tables Converted
4
Primary Keys
1
Foreign Key
8
Secondary Indexes

Mapping Every PostgreSQL Type

PostgreSQLSQL ServerNotes
character varying(n)NVARCHAR(n)Unicode-safe for names/addresses
textNVARCHAR(MAX)No fixed length in source
numeric (no precision)DECIMAL(10,4) / DECIMAL(18,2)Fixed precision chosen per column — coded values vs. currency amounts
bigint (BIGSERIAL-backed)BIGINT IDENTITY(1,1)provider_services.id, data_access_log.log_id
timestamp with time zoneDATETIMEOFFSETPreserves time zone info
inetNVARCHAR(45)No native network-address type in SQL Server
jsonbJSONSQL Server 2025's native JSON type — the one PostgreSQL-specific type that mapped cleanly
Composite primary keyPRIMARY KEY (col1, col2)user_state_access — both columns listed directly

Rewriting the Audit Trigger

The Challenge

PostgreSQL used one generic function, audit.log_data_changes(), shared by both triggers. It relied on dynamic trigger metadata (TG_TABLE_NAME, TG_TABLE_SCHEMA, TG_OP) and to_jsonb(OLD) / to_jsonb(NEW) to log a full row snapshot on every INSERT, UPDATE, and DELETE — regardless of which table fired it.

-- PostgreSQL: one function, shared by every trigger INSERT INTO audit.data_access_log (schema_name, table_name, operation, old_data, new_data) VALUES (TG_TABLE_SCHEMA, TG_TABLE_NAME, TG_OP, to_jsonb(OLD), to_jsonb(NEW));

T-SQL has no equivalent to one function shared across multiple table triggers with dynamic table awareness — every trigger is bound to exactly one table. So I wrote two separate triggers instead of one shared function.

-- T-SQL: per-row JSON snapshot via correlated FOR JSON PATH (SELECT i2.* FROM inserted i2 WHERE i2.rndrng_npi = i.rndrng_npi FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES)
Step 01
Determine the Operation
Compare the inserted/deleted pseudo-tables to determine INSERT, UPDATE, or DELETE for each affected row.
Step 02
Serialize Per Row
A correlated subquery with FOR JSON PATH replicates to_jsonb(OLD)/to_jsonb(NEW), one row at a time.
Step 03
One Trigger Per Table
cms.trg_audit_providers and cms.trg_audit_provider_services, each following the same pattern on their own primary key.

Verification

I tested both triggers by inserting, updating, and deleting a throwaway test row on cms.providers. All three operations logged correctly: INSERT showed NULL old_data with the new row populated, UPDATE showed both old and new data reflecting the actual change, and DELETE preserved the last known state with NULL new_data — then I cleaned up the test rows before moving on.