Skip to content

Commit cd89097

Browse files
committed
inflekt
1 parent 6e7b772 commit cd89097

4 files changed

Lines changed: 474 additions & 0 deletions

File tree

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { inflektTree, camelize, underscore } from '../src';
2+
3+
describe('inflektTree', () => {
4+
describe('basic key transformation', () => {
5+
it('should transform flat object keys from snake_case to camelCase', () => {
6+
const input = { user_name: 'John', user_age: 30 };
7+
const result = inflektTree(input, (key) => camelize(key, true));
8+
expect(result).toEqual({ userName: 'John', userAge: 30 });
9+
});
10+
11+
it('should transform flat object keys from camelCase to snake_case', () => {
12+
const input = { userName: 'John', userAge: 30 };
13+
const result = inflektTree(input, underscore);
14+
expect(result).toEqual({ user_name: 'John', user_age: 30 });
15+
});
16+
17+
it('should handle empty objects', () => {
18+
const result = inflektTree({}, (key) => camelize(key, true));
19+
expect(result).toEqual({});
20+
});
21+
});
22+
23+
describe('nested objects', () => {
24+
it('should transform nested object keys', () => {
25+
const input = {
26+
user_name: 'John',
27+
user_profile: {
28+
profile_image: 'url',
29+
profile_bio: 'bio text',
30+
},
31+
};
32+
const result = inflektTree(input, (key) => camelize(key, true));
33+
expect(result).toEqual({
34+
userName: 'John',
35+
userProfile: {
36+
profileImage: 'url',
37+
profileBio: 'bio text',
38+
},
39+
});
40+
});
41+
42+
it('should handle deeply nested objects', () => {
43+
const input = {
44+
level_one: {
45+
level_two: {
46+
level_three: {
47+
deep_value: 'value',
48+
},
49+
},
50+
},
51+
};
52+
const result = inflektTree(input, (key) => camelize(key, true));
53+
expect(result).toEqual({
54+
levelOne: {
55+
levelTwo: {
56+
levelThree: {
57+
deepValue: 'value',
58+
},
59+
},
60+
},
61+
});
62+
});
63+
});
64+
65+
describe('arrays', () => {
66+
it('should transform keys in array of objects', () => {
67+
const input = {
68+
order_items: [
69+
{ item_id: 1, item_name: 'Product A' },
70+
{ item_id: 2, item_name: 'Product B' },
71+
],
72+
};
73+
const result = inflektTree(input, (key) => camelize(key, true));
74+
expect(result).toEqual({
75+
orderItems: [
76+
{ itemId: 1, itemName: 'Product A' },
77+
{ itemId: 2, itemName: 'Product B' },
78+
],
79+
});
80+
});
81+
82+
it('should handle arrays of primitives', () => {
83+
const input = { user_tags: ['tag1', 'tag2', 'tag3'] };
84+
const result = inflektTree(input, (key) => camelize(key, true));
85+
expect(result).toEqual({ userTags: ['tag1', 'tag2', 'tag3'] });
86+
});
87+
88+
it('should handle nested arrays', () => {
89+
const input = {
90+
data_matrix: [
91+
[{ cell_value: 1 }, { cell_value: 2 }],
92+
[{ cell_value: 3 }, { cell_value: 4 }],
93+
],
94+
};
95+
const result = inflektTree(input, (key) => camelize(key, true));
96+
expect(result).toEqual({
97+
dataMatrix: [
98+
[{ cellValue: 1 }, { cellValue: 2 }],
99+
[{ cellValue: 3 }, { cellValue: 4 }],
100+
],
101+
});
102+
});
103+
});
104+
105+
describe('mixed nested structures', () => {
106+
it('should handle complex mixed structures', () => {
107+
const input = {
108+
user_name: 'John',
109+
order_items: [
110+
{
111+
item_id: 1,
112+
item_details: {
113+
product_name: 'Widget',
114+
product_tags: ['new_arrival', 'sale_item'],
115+
},
116+
},
117+
],
118+
user_metadata: {
119+
created_at: '2024-01-01',
120+
updated_at: '2024-01-02',
121+
},
122+
};
123+
const result = inflektTree(input, (key) => camelize(key, true));
124+
expect(result).toEqual({
125+
userName: 'John',
126+
orderItems: [
127+
{
128+
itemId: 1,
129+
itemDetails: {
130+
productName: 'Widget',
131+
productTags: ['new_arrival', 'sale_item'],
132+
},
133+
},
134+
],
135+
userMetadata: {
136+
createdAt: '2024-01-01',
137+
updatedAt: '2024-01-02',
138+
},
139+
});
140+
});
141+
});
142+
143+
describe('Date preservation', () => {
144+
it('should preserve Date objects', () => {
145+
const date = new Date('2024-01-15T12:00:00Z');
146+
const input = { created_at: date };
147+
const result = inflektTree(input, (key) => camelize(key, true));
148+
149+
expect(result.createdAt).toBeInstanceOf(Date);
150+
expect(result.createdAt.getTime()).toBe(date.getTime());
151+
expect(result.createdAt).not.toBe(date); // Should be a clone
152+
});
153+
154+
it('should preserve nested Date objects', () => {
155+
const date = new Date('2024-01-15T12:00:00Z');
156+
const input = {
157+
user_data: {
158+
last_login: date,
159+
},
160+
};
161+
const result = inflektTree(input, (key) => camelize(key, true));
162+
163+
expect(result.userData.lastLogin).toBeInstanceOf(Date);
164+
expect(result.userData.lastLogin.getTime()).toBe(date.getTime());
165+
});
166+
});
167+
168+
describe('null and undefined handling', () => {
169+
it('should return null for null input', () => {
170+
const result = inflektTree(null, (key) => camelize(key, true));
171+
expect(result).toBeNull();
172+
});
173+
174+
it('should return undefined for undefined input', () => {
175+
const result = inflektTree(undefined, (key) => camelize(key, true));
176+
expect(result).toBeUndefined();
177+
});
178+
179+
it('should preserve null values in objects', () => {
180+
const input = { user_name: null as null, user_age: 30 };
181+
const result = inflektTree(input, (key) => camelize(key, true));
182+
expect(result).toEqual({ userName: null, userAge: 30 });
183+
});
184+
185+
it('should preserve undefined values in objects', () => {
186+
const input = { user_name: undefined as undefined, user_age: 30 };
187+
const result = inflektTree(input, (key) => camelize(key, true));
188+
expect(result).toEqual({ userName: undefined, userAge: 30 });
189+
});
190+
});
191+
192+
describe('primitive inputs', () => {
193+
it('should return primitives as-is', () => {
194+
expect(inflektTree('string', (key) => camelize(key, true))).toBe(
195+
'string'
196+
);
197+
expect(inflektTree(123, (key) => camelize(key, true))).toBe(123);
198+
expect(inflektTree(true, (key) => camelize(key, true))).toBe(true);
199+
});
200+
});
201+
202+
describe('skip option', () => {
203+
it('should skip transformation for keys matching condition', () => {
204+
const input = {
205+
user_name: 'John',
206+
_private_field: 'secret',
207+
_another_private: 'data',
208+
};
209+
const result = inflektTree(input, (key) => camelize(key, true), {
210+
skip: (key) => key.startsWith('_'),
211+
});
212+
expect(result).toEqual({
213+
userName: 'John',
214+
_private_field: 'secret',
215+
_another_private: 'data',
216+
});
217+
});
218+
219+
it('should skip based on path depth', () => {
220+
const input = {
221+
top_level: {
222+
second_level: {
223+
third_level: {
224+
deep_key: 'value',
225+
},
226+
},
227+
},
228+
};
229+
const result = inflektTree(input, (key) => camelize(key, true), {
230+
skip: (key, path) => path.length > 1, // only transform top 2 levels
231+
});
232+
expect(result).toEqual({
233+
topLevel: {
234+
secondLevel: {
235+
third_level: {
236+
deep_key: 'value',
237+
},
238+
},
239+
},
240+
});
241+
});
242+
243+
it('should provide correct path for nested keys', () => {
244+
const paths: Array<{ key: string; path: string[] }> = [];
245+
const input = {
246+
user: {
247+
profile: {
248+
name: 'John',
249+
},
250+
},
251+
};
252+
inflektTree(input, (key) => key, {
253+
skip: (key, path) => {
254+
paths.push({ key, path: [...path] });
255+
return false;
256+
},
257+
});
258+
259+
expect(paths).toEqual([
260+
{ key: 'user', path: [] },
261+
{ key: 'profile', path: ['user'] },
262+
{ key: 'name', path: ['user', 'profile'] },
263+
]);
264+
});
265+
266+
it('should skip specific keys by name', () => {
267+
const input = {
268+
user_name: 'John',
269+
created_at: '2024-01-01',
270+
updated_at: '2024-01-02',
271+
};
272+
const result = inflektTree(input, (key) => camelize(key, true), {
273+
skip: (key) => key === 'created_at' || key === 'updated_at',
274+
});
275+
expect(result).toEqual({
276+
userName: 'John',
277+
created_at: '2024-01-01',
278+
updated_at: '2024-01-02',
279+
});
280+
});
281+
282+
it('should handle skip option with arrays', () => {
283+
const input = {
284+
items: [{ item_id: 1, _meta: 'data' }],
285+
};
286+
const result = inflektTree(input, (key) => camelize(key, true), {
287+
skip: (key) => key.startsWith('_'),
288+
});
289+
expect(result).toEqual({
290+
items: [{ itemId: 1, _meta: 'data' }],
291+
});
292+
});
293+
});
294+
295+
describe('roundtrip transformation', () => {
296+
it('should be able to convert to snake_case and back to camelCase', () => {
297+
const original = { userName: 'John', orderItems: [{ itemId: 1 }] };
298+
const snakeCase = inflektTree(original, underscore);
299+
const backToCamel = inflektTree(snakeCase, (key) => camelize(key, true));
300+
expect(backToCamel).toEqual(original);
301+
});
302+
});
303+
});

packages/inflekt/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
export * from './pluralize';
99
export * from './case';
1010
export * from './naming';
11+
export * from './transform-keys';
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Deep object key transformation utilities
3+
*
4+
* Transform all property names (keys) in an object tree using pluggable inflekt transformers.
5+
*/
6+
7+
export type KeyTransformer = (key: string) => string;
8+
9+
export interface InflektTreeOptions {
10+
/**
11+
* Optional function to skip transformation for specific keys
12+
* @param key - The current key being processed
13+
* @param path - Array of keys representing the path to the current key
14+
* @returns true to skip transformation, false to transform
15+
*/
16+
skip?: (key: string, path: string[]) => boolean;
17+
}
18+
19+
/**
20+
* Recursively traverse an object and transform all property names using the provided transformer.
21+
*
22+
* @param obj - The object to transform
23+
* @param transformer - Function that transforms a key string
24+
* @param options - Optional configuration
25+
* @returns A new object with transformed keys
26+
*
27+
* @example
28+
* // Convert snake_case keys to camelCase
29+
* const apiResponse = { user_name: 'John', order_items: [{ item_id: 1 }] };
30+
* const result = inflektTree(apiResponse, (key) => camelize(key, true));
31+
* // Result: { userName: 'John', orderItems: [{ itemId: 1 }] }
32+
*
33+
* @example
34+
* // Convert camelCase keys to snake_case
35+
* const frontendObj = { userName: 'John', orderItems: [{ itemId: 1 }] };
36+
* const result = inflektTree(frontendObj, underscore);
37+
* // Result: { user_name: 'John', order_items: [{ item_id: 1 }] }
38+
*
39+
* @example
40+
* // Skip keys starting with underscore
41+
* inflektTree(obj, (key) => camelize(key, true), {
42+
* skip: (key) => key.startsWith('_')
43+
* });
44+
*/
45+
export function inflektTree(
46+
obj: any,
47+
transformer: KeyTransformer,
48+
options?: InflektTreeOptions
49+
): any {
50+
return transformKeys(obj, transformer, options, []);
51+
}
52+
53+
function transformKeys(
54+
obj: any,
55+
transformer: KeyTransformer,
56+
options: InflektTreeOptions | undefined,
57+
path: string[]
58+
): any {
59+
// Handle primitives (null, undefined, non-objects)
60+
if (obj == null || typeof obj !== 'object') {
61+
return obj;
62+
}
63+
64+
// Handle Date - clone and return
65+
if (obj instanceof Date) {
66+
return new Date(obj.getTime());
67+
}
68+
69+
// Handle Array - recursively transform each element
70+
if (Array.isArray(obj)) {
71+
return obj.map((item, index) =>
72+
transformKeys(item, transformer, options, path)
73+
);
74+
}
75+
76+
// Handle Object - create new object with transformed keys
77+
const result: Record<string, any> = {};
78+
79+
for (const key of Object.keys(obj)) {
80+
const shouldSkip = options?.skip?.(key, path) ?? false;
81+
const newKey = shouldSkip ? key : transformer(key);
82+
const newPath = [...path, key];
83+
84+
result[newKey] = transformKeys(obj[key], transformer, options, newPath);
85+
}
86+
87+
return result;
88+
}

0 commit comments

Comments
 (0)