Skip to content

Commit

Permalink
create aggregateObjects and flattenObject helpers (#29)
Browse files Browse the repository at this point in the history
* create aggregateObjects and flattenObject helpers

* update package version

* nit

* fix pre-commit

* add commments
  • Loading branch information
abrantesarthur authored Jan 14, 2025
1 parent db7e059 commit 84d82fb
Show file tree
Hide file tree
Showing 7 changed files with 374 additions and 2 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"author": "Transcend Inc.",
"name": "@transcend-io/type-utils",
"description": "Small package containing useful typescript utilities.",
"version": "1.6.0",
"version": "1.7.0",
"homepage": "https://github.com/transcend-io/type-utils",
"repository": {
"type": "git",
Expand Down
50 changes: 50 additions & 0 deletions src/aggregateObjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// eslint-disable-next-line eslint-comments/disable-enable-pair
/* eslint-disable @typescript-eslint/no-explicit-any */

/**
*
* Aggregates multiple objects into a single object by combining values of matching keys.
* For each key present in any of the input objects, creates a comma-separated string
* of values from all objects.
* @param param - the objects to aggregate and the aggregation method
* @returns a single object containing all unique keys with aggregated values
* @example
* const obj1 = { name: 'John', age: 30 };
* const obj2 = { name: 'Jane', city: 'NY' };
* const obj3 = { name: 'Bob', age: 25 };
*
* // Without wrap
* aggregateObjects({ objs: [obj1, obj2, obj3] })
* // Returns: { name: 'John,Jane,Bob', age: '30,,25', city: ',NY,' }
*
* // With wrap
* aggregateObjects({ objs: [obj1, obj2, obj3], wrap: true })
* // Returns: { name: '[John],[Jane],[Bob]', age: '[30],[],[25]', city: '[],[NY],[]' }
*/
export const aggregateObjects = ({
objs,
wrap = false,
}: {
/** the objects to aggregate in a single one */
objs: any[];
/** whether to wrap the concatenated values in a [] */
wrap?: boolean;
}): any => {
const allKeys = Array.from(
new Set(
objs.flatMap((a) => (a && typeof a === 'object' ? Object.keys(a) : [])),
),
);

// Reduce into a single object, where each key contains concatenated values from all input objects
return allKeys.reduce(
(acc, key) => {
const values = objs
.map((o) => (wrap ? `[${o?.[key] ?? ''}]` : o?.[key] ?? ''))
.join(',');
acc[key] = values;
return acc;
},
{} as Record<string, any>,
);
};
94 changes: 94 additions & 0 deletions src/flattenObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// eslint-disable-next-line eslint-comments/disable-enable-pair
/* eslint-disable @typescript-eslint/no-explicit-any */

import { aggregateObjects } from './aggregateObjects';

/**
*
* Flattens a nested object into a single-level object with concatenated key names. Useful
* for converting objects to CSV.
* @param param - The information about the object to flatten
* @returns A flattened object where nested keys are joined with underscores
* @example
* const nested = {
* user: {
* name: 'John',
* address: {
* city: 'NY',
* zip: 10001
* },
* hobbies: ['reading', 'gaming']
* }
* };
*
* flattenObject(nested)
* // Returns: {
* // user_name: 'John',
* // user_address_city: 'NY',
* // user_address_zip: 10001,
* // user_hobbies: 'reading,gaming'
* // }
*/
export const flattenObject = ({
obj,
prefix = '',
}: {
/** the object to flatten */
obj: any;
/** The prefix to prepend to keys (used in recursion) */
prefix?: string;
}): any =>
!obj
? {}
: Object.keys(obj ?? []).reduce(
(acc, key) => {
const newKey = prefix ? `${prefix}_${key}` : key;
const entry = obj[key];

// Handle arrays of objects
if (
Array.isArray(entry) &&
entry.length > 0 &&
entry.some((item) => typeof item === 'object' && item !== null)
) {
// Flatten each object in the array
const objEntries = entry.filter(
(item) => typeof item === 'object' && item !== null,
);
const flattenedObjects = objEntries.map((item) =>
flattenObject({ obj: item }),
);
// Aggregate the flattened objects
const aggregated = aggregateObjects({ objs: flattenedObjects });
// Add prefix to all keys
Object.entries(aggregated).forEach(([k, v]) => {
acc[`${newKey}_${k}`] = v;
});
}
// Handle regular objects
else if (
typeof entry === 'object' &&
entry !== null &&
!Array.isArray(entry)
) {
Object.assign(acc, flattenObject({ obj: entry, prefix: newKey }));
}
// Handle primitive arrays and other values
else {
acc[newKey] = Array.isArray(entry)
? entry
.map((e) => {
if (typeof e === 'string') {
return e.replaceAll(',', '');
}
return e ?? '';
})
.join(',')
: typeof entry === 'string'
? entry.replaceAll(',', '')
: entry ?? '';
}
return acc;
},
{} as Record<string, any>,
);
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './invert';
export * from './types';
export * from './valuesOf';
export * from './findAllWithRegex';
export * from './flattenObject';
106 changes: 106 additions & 0 deletions src/tests/aggregateObjects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect } from 'chai';
import { aggregateObjects } from '../aggregateObjects';

describe.only('aggregateObjects', () => {
it('should return empty object for empty input array', () => {
const result = aggregateObjects({ objs: [] });
expect(result).to.deep.equal({});
});

it('should aggregate objects with same keys', () => {
const objs = [
{ name: 'John', age: 30 },
{ name: 'Jane', age: 25 },
{ name: 'Bob', age: 35 },
];
const result = aggregateObjects({ objs });
expect(result).to.deep.equal({
name: 'John,Jane,Bob',
age: '30,25,35',
});
});

it('should handle missing properties with missing keys', () => {
const objs = [
{ name: 'John', age: 30 },
{ name: 'Jane' },
{ name: 'Bob', age: 35 },
];
const result = aggregateObjects({ objs });
expect(result).to.deep.equal({
name: 'John,Jane,Bob',
age: '30,,35',
});
});

it('should handle null and undefined values', () => {
const objs = [
{ name: 'John', age: null },
{ name: undefined, hobby: 'reading' },
{ name: 'Bob', hobby: null },
];
const result = aggregateObjects({ objs });
expect(result).to.deep.equal({
name: 'John,,Bob',
age: ',,',
hobby: ',reading,',
});
});

it('should wrap values in brackets when wrap option is true', () => {
const objs = [
{ name: 'John', age: 30 },
{ name: 'Jane' },
{ name: 'Bob', age: 35 },
];
const result = aggregateObjects({ objs, wrap: true });
expect(result).to.deep.equal({
name: '[John],[Jane],[Bob]',
age: '[30],[],[35]',
});
});

it('should handle objects with different keys', () => {
const objs = [
{ name: 'John', age: 30 },
{ city: 'NY', country: 'USA' },
{ name: 'Bob', country: 'UK' },
];
const result = aggregateObjects({ objs });
expect(result).to.deep.equal({
name: 'John,,Bob',
age: '30,,',
city: ',NY,',
country: ',USA,UK',
});
});

it('should handle empty objects', () => {
const objs = [{ name: 'John' }, {}, { name: 'Bob' }];
const result = aggregateObjects({ objs });
expect(result).to.deep.equal({
name: 'John,,Bob',
});
});

it('should handle array with null or undefined objects', () => {
const objs = [{ name: 'John' }, null, undefined, { name: 'Bob' }];
const result = aggregateObjects({ objs });
expect(result).to.deep.equal({
name: 'John,,,Bob',
});
});

it('should handle numeric and boolean values', () => {
const objs = [
{ count: 1, active: true },
{ count: 2, active: false },
{ count: 3, active: true },
];
const result = aggregateObjects({ objs });
expect(result).to.deep.equal({
count: '1,2,3',
active: 'true,false,true',
});
});
});
2 changes: 1 addition & 1 deletion src/tests/createDefaultCodec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createDefaultCodec } from '../codecTools';

chai.use(deepEqualInAnyOrder);

describe.only('buildDefaultCodec', () => {
describe('buildDefaultCodec', () => {
it('should correctly build a default codec for null', () => {
const result = createDefaultCodec(t.null);
expect(result).to.equal(null);
Expand Down
121 changes: 121 additions & 0 deletions src/tests/flattenObject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { expect } from 'chai';
import { flattenObject } from '../flattenObject';

describe('flattenObject', () => {
it('should return empty object for null input', () => {
const result = flattenObject({ obj: null });
expect(result).to.deep.equal({});
});

it('should return empty object for undefined input', () => {
let obj;
const result = flattenObject({ obj });
expect(result).to.deep.equal({});
});

it('should flatten list of objects with some entries missing properties', () => {
// create a list of users with one of them missiging the age property
const obj = {
users: [
{
name: 'Bob',
},
{
name: 'Alice',
age: 18,
},
],
};
const result = flattenObject({ obj });
// the flattened object should include the missing age property
expect(result).to.deep.equal({
users_name: 'Bob,Alice',
users_age: ',18',
});
});

it('should ignore primitive and null types within list containing objects', () => {
// create a list of a mix of objects, strings, and null
const obj = {
users: [
'[email protected]',
null,
{
name: 'Bob',
},
{
name: 'Alice',
age: 18,
},
],
};
const result = flattenObject({ obj });
// the flattened object should not include the strings
expect(result).to.deep.equal({
users_name: 'Bob,Alice',
users_age: ',18',
});
});

it('should flatten an object with null entries', () => {
const obj = {
user: {
siblings: null,
},
};
const result = flattenObject({ obj });
expect(result).to.deep.equal({
user_siblings: '',
});
});

it('should flatten an object with empty entries', () => {
const obj = {
user: {
siblings: [],
},
};
const result = flattenObject({ obj });
expect(result).to.deep.equal({
user_siblings: '',
});
});

it('should flatten a deep nested object', () => {
const obj = {
user: {
name: 'John',
address: {
city: 'NY',
zip: 10001,
},
hobbies: ['reading', 'gaming'],
parents: [
{
name: 'Alice',
biological: true,
age: 52,
},
{
name: 'Bob',
biological: false,
},
],
siblings: [],
grandParents: null,
},
};
const result = flattenObject({ obj });
expect(result).to.deep.equal({
user_name: 'John',
user_address_city: 'NY',
user_address_zip: 10001,
user_hobbies: 'reading,gaming',
user_parents_name: 'Alice,Bob',
user_parents_biological: 'true,false',
user_parents_age: '52,',
user_siblings: '',
user_grandParents: '',
});
});
});

0 comments on commit 84d82fb

Please sign in to comment.