Skip to content

UUID Comparison Guide for MemberJunction

The Problem: UUID Case Sensitivity Across Database Platforms

Section titled “The Problem: UUID Case Sensitivity Across Database Platforms”

MemberJunction supports both SQL Server and PostgreSQL as database backends. These platforms handle UUID casing differently:

DatabaseUUID Return FormatExample
SQL ServerUPPERCASEA1B2C3D4-E5F6-7890-ABCD-EF1234567890
PostgreSQLlowercasea1b2c3d4-e5f6-7890-abcd-ef1234567890

Both formats represent the same UUID and are semantically identical. However, JavaScript’s === operator performs a case-sensitive string comparison, which means:

// This FAILS when comparing UUIDs from different database platforms
'A1B2C3D4-E5F6-7890-ABCD-EF1234567890' === 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
// Result: false (WRONG!)

This breaks any code that uses === or !== to compare UUID values — lookups fail silently, filters return empty results, and data appears missing.

The Solution: UUIDsEqual() and NormalizeUUID()

Section titled “The Solution: UUIDsEqual() and NormalizeUUID()”

MemberJunction provides two utility functions in @memberjunction/global that handle cross-platform UUID comparison correctly:

Case-insensitive comparison of two UUID strings. Handles null/undefined gracefully.

import { UUIDsEqual } from '@memberjunction/global';
// Cross-platform comparison works correctly
UUIDsEqual('A1B2C3D4-E5F6-7890-ABCD-EF1234567890',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890') // true
// Null safety
UUIDsEqual(null, null) // true
UUIDsEqual(null, 'some-id') // false

Normalizes a UUID to lowercase for use in Set, Map, or other data structures that need consistent key formatting.

import { NormalizeUUID } from '@memberjunction/global';
NormalizeUUID('A1B2C3D4-E5F6-7890-ABCD-EF1234567890')
// Returns: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
NormalizeUUID(null) // Returns: ''

Pattern 1: Finding an item by ID (.find())

Section titled “Pattern 1: Finding an item by ID (.find())”
// WRONG - breaks with cross-platform UUIDs
const server = this._Servers.find(s => s.ID === serverId);
// CORRECT
import { UUIDsEqual } from '@memberjunction/global';
const server = this._Servers.find(s => UUIDsEqual(s.ID, serverId));

Pattern 2: Filtering by a foreign key (.filter())

Section titled “Pattern 2: Filtering by a foreign key (.filter())”
// WRONG
const userApps = allUserApps.filter(ua => ua.UserID === userId);
// CORRECT
const userApps = allUserApps.filter(ua => UUIDsEqual(ua.UserID, userId));
// WRONG
const exists = items.some(item => item.ID === targetId);
// CORRECT
const exists = items.some(item => UUIDsEqual(item.ID, targetId));
// WRONG
const others = items.filter(item => item.ID !== excludeId);
// CORRECT
const others = items.filter(item => !UUIDsEqual(item.ID, excludeId));

Pattern 5: Sets and maps of UUIDs — UUIDSet / UUIDMap

Section titled “Pattern 5: Sets and maps of UUIDs — UUIDSet / UUIDMap”

A native Set or Map compares keys with ===, so an upper-case ID never matches its lower-case twin. Use UUIDSet and UUIDMap: they normalize on every insert and every lookup, so there is no second side to forget.

import { UUIDSet, UUIDMap } from '@memberjunction/global';
// WRONG - Set uses === internally
const idSet = new Set(items.map(i => i.ID));
idSet.has(lookupId); // fails if cases differ
// CORRECT
const idSet = new UUIDSet(items.map(i => i.ID));
idSet.Has(lookupId); // any casing, surrounding whitespace ignored
const roleNames = new UUIDMap(roles.map(r => [r.ID, r.Name]));
roleNames.Get(permission.RoleID);

A hand-built new Set(ids.map(id => NormalizeUUID(id))) also works if every lookup uses NormalizeUUID too. The types exist so nobody has to remember that. Both follow NormalizeUUID exactly, including null/undefined normalizing to ''.

In Angular inline templates, you cannot call imported functions directly. Add helper methods to the component class:

// In the component class:
import { UUIDsEqual } from '@memberjunction/global';
export class MyComponent {
selectedId: string | null = null;
IsSelected(itemId: string): boolean {
return this.selectedId != null && UUIDsEqual(this.selectedId, itemId);
}
}
// In the template:
// WRONG: [class.selected]="item.ID === selectedId"
// CORRECT: [class.selected]="IsSelected(item.ID)"

Pattern 7: Conditional assignment / toggle

Section titled “Pattern 7: Conditional assignment / toggle”
// WRONG
this.selectedId = this.selectedId === itemId ? null : itemId;
// CORRECT
this.selectedId = (this.selectedId != null && UUIDsEqual(this.selectedId, itemId))
? null
: itemId;

Pattern 8: Matching one list against another — use the set-based helpers

Section titled “Pattern 8: Matching one list against another — use the set-based helpers”

UUIDsEqual() compares two IDs, so matching a list against a list with it means a nested scan: n × m comparisons. That’s invisible with five items and pegs a CPU with a few hundred, especially in code that re-runs on every render (an Angular getter or a template-bound method). A single top-level .find() (Patterns 1–3) is fine.

Every nested scan is one of five operations, and each has a helper that does it in one linear pass:

You wantUse
Items whose ID is in a listFilterByUUIDs(items, ids)
Items whose ID is not in a listExcludeByUUIDs(items, ids)
How many items share each IDCountByUUID(items).Get(id) ?? 0
The items that share each IDGroupByUUID(items, x => x.ParentID).Get(id) ?? [] (each group in items order)
The item for an ID, looked up repeatedlyIndexByUUID(items).Get(id) (the first item wins, like find)
import { FilterByUUIDs, ExcludeByUUIDs, CountByUUID, GroupByUUID, IndexByUUID } from '@memberjunction/global';
// SLOW - n x m comparisons
const picked = roles.filter(r => selectedIds.some(id => UUIDsEqual(id, r.ID)));
// CORRECT - linear
const picked = FilterByUUIDs(roles, selectedIds);
// A different ID field: pass a selector as the third argument
const stillLoading = ExcludeByUUIDs(pending, loadedIds, p => p.artifactId);
const perEntity = CountByUUID(relationships, r => r.RelatedEntityID);
const rolesByUser = GroupByUUID(userRoles, r => r.UserID);

Every helper keeps the items’ own order, accepts IDs in any casing, and takes an existing UUIDSet in place of the ID list. The selector defaults to item => item.ID.

Not every .ID === comparison involves UUIDs. These patterns are safe with ===:

  • Numeric IDs: Integer primary keys (number type) are not affected
  • String literal comparisons: status === 'Active', type === 'Report'
  • Comparing to null/undefined: item.ID == null is fine
  • Within-transaction comparisons: If both values come from the same query result and haven’t been cached, they’ll have the same case

Even if you’re currently developing against only one database platform, using UUIDsEqual() is important because:

  1. Future-proofing: MemberJunction is designed to support multiple database backends. Code that works on SQL Server today should work on PostgreSQL tomorrow without changes.

  2. Cached data mixing: GraphQL data providers cache entity metadata. If a user switches backends behind the same endpoint, stale cached data with one case can be compared against fresh data with the opposite case.

  3. External integrations: UUIDs may arrive from external systems (APIs, webhooks, MCP servers) in any case format. Robust comparison prevents silent failures.

  4. Silent failures are costly: A === comparison that returns false for matching UUIDs doesn’t throw an error — it silently returns no results. These bugs are extremely difficult to diagnose in production.

packages/MJGlobal/src/__tests__/UUIDCompliance.test.ts scans all packages/ source and fails on:

  1. Direct comparisons — .ID === / !== / .includes(x.ID) patterns that should use UUIDsEqual() or a UUIDSet. A legitimate non-UUID comparison (e.g. a numeric ID) goes in KNOWN_EXCEPTIONS.
  2. Nested scans — a .some / .every / .find / .findIndex / .filter calling UUIDsEqual inside the callback of another array iteration or inside a loop (Pattern 8). Detection uses the TypeScript AST, so chaining (list.filter(…).find(…)) is not flagged. It only sees a direct UUIDsEqual call: a scan wrapped in a helper function and called from inside the iteration (for example rows.filter(r => isChild(r)), where isChild runs .some(UUIDsEqual)) is not detected, so review such helpers by hand. Sites that predate the check are recorded per file in NESTED_SCAN_BASELINE, which is a ratchet: a file may not gain sites, and when you fix one the check makes you lower the number.

It runs uncached in the Source guards CI job. To run it locally:

Terminal window
pnpm --filter @memberjunction/global exec vitest run --config ../../vitest.shared.ts --passWithNoTests=false src/__tests__/UUIDCompliance.test.ts

The file packages/MJCoreEntities/src/__tests__/UUIDCrossDbCompliance.test.ts verifies that engine lookup methods (e.g., GetServerById, GetKeyByID, GetAccountById) work correctly with mixed-case UUIDs. Each test stores data with one case and looks it up with the opposite case, simulating a cross-database scenario.

Instead of…Use…
a.ID === b.IDUUIDsEqual(a.ID, b.ID)
a.ID !== b.ID!UUIDsEqual(a.ID, b.ID)
items.find(x => x.ID === id)items.find(x => UUIDsEqual(x.ID, id))
items.filter(x => x.UserID === uid)items.filter(x => UUIDsEqual(x.UserID, uid))
items.some(x => x.ID === id)items.some(x => UUIDsEqual(x.ID, id))
new Set(ids); set.has(id)new UUIDSet(ids); set.Has(id)
Template: item.ID === selectedIdAdd component method using UUIDsEqual()
a.filter(x => b.some(y => UUIDsEqual(x.ID, y)))FilterByUUIDs(a, b)
a.filter(x => !b.some(y => UUIDsEqual(x.ID, y)))ExcludeByUUIDs(a, b)
for (…) { list.find(x => UUIDsEqual(x.ID, id)) }const byID = IndexByUUID(list); then byID.Get(id)
for (…) { list.filter(x => UUIDsEqual(x.ParentID, id)) }const byParent = GroupByUUID(list, x => x.ParentID); then byParent.Get(id) ?? []

Everything is exported from @memberjunction/global:

import { UUIDsEqual, NormalizeUUID, UUIDSet, UUIDMap, FilterByUUIDs, ExcludeByUUIDs, CountByUUID, GroupByUUID, IndexByUUID } from '@memberjunction/global';

Sources: packages/MJGlobal/src/util/UUIDUtils.ts, packages/MJGlobal/src/util/UUIDCollections.ts