Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions packages/normy/src/denormalize.circular.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { denormalize } from './denormalize';

describe('denormalize - circular references', () => {
it('should handle simple circular references without infinite loops', () => {
const normalizedData = {
'@@user1': {
id: 'user1',
name: 'Alice',
createdProjects: ['@@project1'],
},
'@@project1': {
id: 'project1',
name: 'Test Project',
createdBy: '@@user1', // Circular reference back to user
},
};

const data = '@@user1';
const result = denormalize(data, normalizedData, {}, '');

expect(result).toEqual({
id: 'user1',
name: 'Alice',
createdProjects: [
{
id: 'project1',
name: 'Test Project',
createdBy: '@@user1', // Reference string returned to prevent infinite loop
},
],
});
});

it('should handle self-referencing entities', () => {
const normalizedData = {
'@@node1': {
id: 'node1',
name: 'Root Node',
parent: '@@node1', // Self-reference
},
};

const data = '@@node1';
const result = denormalize(data, normalizedData, {}, '');

expect(result).toEqual({
id: 'node1',
name: 'Root Node',
parent: '@@node1', // Reference string returned instead of infinite recursion
});
});

it('should handle complex circular reference chains', () => {
const normalizedData = {
'@@a': {
id: 'a',
next: '@@b',
},
'@@b': {
id: 'b',
next: '@@c',
},
'@@c': {
id: 'c',
next: '@@a', // Circular reference back to a
},
};

const data = '@@a';
const result = denormalize(data, normalizedData, {}, '');

expect(result).toEqual({
id: 'a',
next: {
id: 'b',
next: {
id: 'c',
next: '@@a', // Reference string returned to break the cycle
},
},
});
});

it('should handle circular references in arrays', () => {
const normalizedData = {
'@@user1': {
id: 'user1',
name: 'User 1',
friends: ['@@user2', '@@user3'],
},
'@@user2': {
id: 'user2',
name: 'User 2',
friends: ['@@user1', '@@user3'], // Circular reference back to user1
},
'@@user3': {
id: 'user3',
name: 'User 3',
friends: ['@@user1', '@@user2'], // References to both
},
};

const data = '@@user1';
const result = denormalize(data, normalizedData, {}, '');

expect(result).toEqual({
id: 'user1',
name: 'User 1',
friends: [
{
id: 'user2',
name: 'User 2',
friends: ['@@user1', '@@user3'], // Circular references prevented
},
{
id: 'user3',
name: 'User 3',
friends: ['@@user1', '@@user2'], // Circular references prevented
},
],
});
});

it('should allow the same reference at different paths', () => {
const normalizedData = {
'@@user1': {
id: 'user1',
name: 'Alice',
},
'@@project1': {
id: 'project1',
owner: '@@user1',
lastModifiedBy: '@@user1', // Same reference but different path
},
};

const data = '@@project1';
const result = denormalize(data, normalizedData, {}, '');

// Both references should be denormalized since they're at different paths
expect(result).toEqual({
id: 'project1',
owner: {
id: 'user1',
name: 'Alice',
},
lastModifiedBy: {
id: 'user1',
name: 'Alice',
},
});
});

it('should handle deeply nested circular references', () => {
const normalizedData = {
'@@parent': {
id: 'parent',
child: {
nested: {
deep: {
reference: '@@parent', // Circular reference from deep nesting
},
},
},
},
};

const data = '@@parent';
const result = denormalize(data, normalizedData, {}, '');

expect(result).toEqual({
id: 'parent',
child: {
nested: {
deep: {
reference: '@@parent', // Reference string returned
},
},
},
});
});
});
16 changes: 13 additions & 3 deletions packages/normy/src/denormalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@ export const denormalize = (
normalizedData: { [key: string]: Data },
usedKeys: UsedKeys,
path = '',
seenRefs = new Set<string>(),
): Data => {
// Handle circular references
if (typeof data === 'string' && data.startsWith('@@')) {
return denormalize(normalizedData[data], normalizedData, usedKeys, path);
// Check if we've already seen this reference in the current traversal
if (seenRefs.has(data)) {
// Circular reference detected, return the reference string itself
return data;
}
// Add to seen set before recursing
const newSeenRefs = new Set(seenRefs);
newSeenRefs.add(data);
return denormalize(normalizedData[data], normalizedData, usedKeys, path, newSeenRefs);
} else if (Array.isArray(data)) {
return data.map(value =>
denormalize(value, normalizedData, usedKeys, path),
denormalize(value, normalizedData, usedKeys, path, seenRefs),
) as DataPrimitiveArray | DataObject[];
} else if (
data !== null &&
Expand All @@ -22,7 +32,7 @@ export const denormalize = (
: Object.entries(data);

return objectEntries.reduce((prev, [k, v]) => {
prev[k] = denormalize(v, normalizedData, usedKeys, `${path}.${k}`);
prev[k] = denormalize(v, normalizedData, usedKeys, `${path}.${k}`, seenRefs);

return prev;
}, {} as DataObject);
Expand Down