diff --git a/FOOTER.md b/FOOTER.md index 2acd431..e5cf940 100644 --- a/FOOTER.md +++ b/FOOTER.md @@ -25,8 +25,7 @@ pnpm test:watch ## Credits -Built for developers, with developers. -👉 https://launchql.com | https://hyperweb.io +**🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).** ## Disclaimer diff --git a/packages/inflekt/README.md b/packages/inflekt/README.md index 3ef59d1..1d199a9 100644 --- a/packages/inflekt/README.md +++ b/packages/inflekt/README.md @@ -38,6 +38,7 @@ import { underscore, toFieldName, toQueryName, + inflektTree, } from 'inflekt'; // Basic singularization/pluralization @@ -65,6 +66,14 @@ underscore('UserProfile'); // 'user_profile' // GraphQL naming helpers toFieldName('Users'); // 'user' toQueryName('User'); // 'users' + +// Deep object key transformation +const apiResponse = { + user_name: 'John', + order_items: [{ item_id: 1, product_name: 'Widget' }] +}; +inflektTree(apiResponse, (key) => camelize(key, true)); +// Result: { userName: 'John', orderItems: [{ itemId: 1, productName: 'Widget' }] } ``` ## API @@ -91,6 +100,86 @@ toQueryName('User'); // 'users' - `toFieldName(pluralTypeName)` - Convert plural PascalCase to singular camelCase field name - `toQueryName(singularTypeName)` - Convert singular PascalCase to plural camelCase query name +### Deep Object Transformation + +- `inflektTree(obj, transformer, options?)` - Recursively transform all property names in an object tree + +## Deep Object Key Transformation + +The `inflektTree` function recursively transforms all property names in an object tree using any transformer function. This is useful for converting API responses between naming conventions. + +### Basic Usage + +```typescript +// Convert snake_case API response to camelCase for frontend +const apiResponse = { + user_name: 'John', + user_profile: { + profile_image: 'https://example.com/avatar.jpg', + account_status: 'active' + }, + order_items: [ + { item_id: 1, item_name: 'Product A' }, + { item_id: 2, item_name: 'Product B' } + ] +}; + +const result = inflektTree(apiResponse, (key) => camelize(key, true)); +// Result: +// { +// userName: 'John', +// userProfile: { +// profileImage: 'https://example.com/avatar.jpg', +// accountStatus: 'active' +// }, +// orderItems: [ +// { itemId: 1, itemName: 'Product A' }, +// { itemId: 2, itemName: 'Product B' } +// ] +// } + +// Convert camelCase frontend data to snake_case for API +const frontendData = { userName: 'John', orderItems: [{ itemId: 1 }] }; +const payload = inflektTree(frontendData, underscore); +// Result: { user_name: 'John', order_items: [{ item_id: 1 }] } +``` + +### Skipping Keys + +Use the `skip` option to preserve certain keys: + +```typescript +// Skip keys starting with underscore +const input = { + user_name: 'John', + _private_field: 'secret', + _metadata: { _internal: true } +}; + +const result = inflektTree(input, (key) => camelize(key, true), { + skip: (key) => key.startsWith('_') +}); +// Result: { userName: 'John', _private_field: 'secret', _metadata: { _internal: true } } + +// Skip specific keys +const result2 = inflektTree(input, (key) => camelize(key, true), { + skip: (key) => key === 'created_at' || key === 'updated_at' +}); + +// Skip based on path depth (only transform top 2 levels) +const result3 = inflektTree(deepObject, (key) => camelize(key, true), { + skip: (key, path) => path.length > 1 +}); +``` + +### Features + +- Handles nested objects and arrays of any depth +- Preserves `Date` objects (clones them) +- Preserves `null` and `undefined` values +- Returns primitives unchanged +- Works with any transformer function + ## Latin Suffix Overrides This library handles Latin plural suffixes differently than the standard `inflection` package to match PostGraphile's behavior: diff --git a/packages/inflekt/__tests__/transform-keys.test.ts b/packages/inflekt/__tests__/transform-keys.test.ts new file mode 100644 index 0000000..76e4769 --- /dev/null +++ b/packages/inflekt/__tests__/transform-keys.test.ts @@ -0,0 +1,303 @@ +import { inflektTree, camelize, underscore } from '../src'; + +describe('inflektTree', () => { + describe('basic key transformation', () => { + it('should transform flat object keys from snake_case to camelCase', () => { + const input = { user_name: 'John', user_age: 30 }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ userName: 'John', userAge: 30 }); + }); + + it('should transform flat object keys from camelCase to snake_case', () => { + const input = { userName: 'John', userAge: 30 }; + const result = inflektTree(input, underscore); + expect(result).toEqual({ user_name: 'John', user_age: 30 }); + }); + + it('should handle empty objects', () => { + const result = inflektTree({}, (key) => camelize(key, true)); + expect(result).toEqual({}); + }); + }); + + describe('nested objects', () => { + it('should transform nested object keys', () => { + const input = { + user_name: 'John', + user_profile: { + profile_image: 'url', + profile_bio: 'bio text', + }, + }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ + userName: 'John', + userProfile: { + profileImage: 'url', + profileBio: 'bio text', + }, + }); + }); + + it('should handle deeply nested objects', () => { + const input = { + level_one: { + level_two: { + level_three: { + deep_value: 'value', + }, + }, + }, + }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ + levelOne: { + levelTwo: { + levelThree: { + deepValue: 'value', + }, + }, + }, + }); + }); + }); + + describe('arrays', () => { + it('should transform keys in array of objects', () => { + const input = { + order_items: [ + { item_id: 1, item_name: 'Product A' }, + { item_id: 2, item_name: 'Product B' }, + ], + }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ + orderItems: [ + { itemId: 1, itemName: 'Product A' }, + { itemId: 2, itemName: 'Product B' }, + ], + }); + }); + + it('should handle arrays of primitives', () => { + const input = { user_tags: ['tag1', 'tag2', 'tag3'] }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ userTags: ['tag1', 'tag2', 'tag3'] }); + }); + + it('should handle nested arrays', () => { + const input = { + data_matrix: [ + [{ cell_value: 1 }, { cell_value: 2 }], + [{ cell_value: 3 }, { cell_value: 4 }], + ], + }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ + dataMatrix: [ + [{ cellValue: 1 }, { cellValue: 2 }], + [{ cellValue: 3 }, { cellValue: 4 }], + ], + }); + }); + }); + + describe('mixed nested structures', () => { + it('should handle complex mixed structures', () => { + const input = { + user_name: 'John', + order_items: [ + { + item_id: 1, + item_details: { + product_name: 'Widget', + product_tags: ['new_arrival', 'sale_item'], + }, + }, + ], + user_metadata: { + created_at: '2024-01-01', + updated_at: '2024-01-02', + }, + }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ + userName: 'John', + orderItems: [ + { + itemId: 1, + itemDetails: { + productName: 'Widget', + productTags: ['new_arrival', 'sale_item'], + }, + }, + ], + userMetadata: { + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + }, + }); + }); + }); + + describe('Date preservation', () => { + it('should preserve Date objects', () => { + const date = new Date('2024-01-15T12:00:00Z'); + const input = { created_at: date }; + const result = inflektTree(input, (key) => camelize(key, true)); + + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.createdAt.getTime()).toBe(date.getTime()); + expect(result.createdAt).not.toBe(date); // Should be a clone + }); + + it('should preserve nested Date objects', () => { + const date = new Date('2024-01-15T12:00:00Z'); + const input = { + user_data: { + last_login: date, + }, + }; + const result = inflektTree(input, (key) => camelize(key, true)); + + expect(result.userData.lastLogin).toBeInstanceOf(Date); + expect(result.userData.lastLogin.getTime()).toBe(date.getTime()); + }); + }); + + describe('null and undefined handling', () => { + it('should return null for null input', () => { + const result = inflektTree(null, (key) => camelize(key, true)); + expect(result).toBeNull(); + }); + + it('should return undefined for undefined input', () => { + const result = inflektTree(undefined, (key) => camelize(key, true)); + expect(result).toBeUndefined(); + }); + + it('should preserve null values in objects', () => { + const input = { user_name: null as null, user_age: 30 }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ userName: null, userAge: 30 }); + }); + + it('should preserve undefined values in objects', () => { + const input = { user_name: undefined as undefined, user_age: 30 }; + const result = inflektTree(input, (key) => camelize(key, true)); + expect(result).toEqual({ userName: undefined, userAge: 30 }); + }); + }); + + describe('primitive inputs', () => { + it('should return primitives as-is', () => { + expect(inflektTree('string', (key) => camelize(key, true))).toBe( + 'string' + ); + expect(inflektTree(123, (key) => camelize(key, true))).toBe(123); + expect(inflektTree(true, (key) => camelize(key, true))).toBe(true); + }); + }); + + describe('skip option', () => { + it('should skip transformation for keys matching condition', () => { + const input = { + user_name: 'John', + _private_field: 'secret', + _another_private: 'data', + }; + const result = inflektTree(input, (key) => camelize(key, true), { + skip: (key) => key.startsWith('_'), + }); + expect(result).toEqual({ + userName: 'John', + _private_field: 'secret', + _another_private: 'data', + }); + }); + + it('should skip based on path depth', () => { + const input = { + top_level: { + second_level: { + third_level: { + deep_key: 'value', + }, + }, + }, + }; + const result = inflektTree(input, (key) => camelize(key, true), { + skip: (key, path) => path.length > 1, // only transform top 2 levels + }); + expect(result).toEqual({ + topLevel: { + secondLevel: { + third_level: { + deep_key: 'value', + }, + }, + }, + }); + }); + + it('should provide correct path for nested keys', () => { + const paths: Array<{ key: string; path: string[] }> = []; + const input = { + user: { + profile: { + name: 'John', + }, + }, + }; + inflektTree(input, (key) => key, { + skip: (key, path) => { + paths.push({ key, path: [...path] }); + return false; + }, + }); + + expect(paths).toEqual([ + { key: 'user', path: [] }, + { key: 'profile', path: ['user'] }, + { key: 'name', path: ['user', 'profile'] }, + ]); + }); + + it('should skip specific keys by name', () => { + const input = { + user_name: 'John', + created_at: '2024-01-01', + updated_at: '2024-01-02', + }; + const result = inflektTree(input, (key) => camelize(key, true), { + skip: (key) => key === 'created_at' || key === 'updated_at', + }); + expect(result).toEqual({ + userName: 'John', + created_at: '2024-01-01', + updated_at: '2024-01-02', + }); + }); + + it('should handle skip option with arrays', () => { + const input = { + items: [{ item_id: 1, _meta: 'data' }], + }; + const result = inflektTree(input, (key) => camelize(key, true), { + skip: (key) => key.startsWith('_'), + }); + expect(result).toEqual({ + items: [{ itemId: 1, _meta: 'data' }], + }); + }); + }); + + describe('roundtrip transformation', () => { + it('should be able to convert to snake_case and back to camelCase', () => { + const original = { userName: 'John', orderItems: [{ itemId: 1 }] }; + const snakeCase = inflektTree(original, underscore); + const backToCamel = inflektTree(snakeCase, (key) => camelize(key, true)); + expect(backToCamel).toEqual(original); + }); + }); +}); diff --git a/packages/inflekt/src/index.ts b/packages/inflekt/src/index.ts index 4471786..d9d12e4 100644 --- a/packages/inflekt/src/index.ts +++ b/packages/inflekt/src/index.ts @@ -8,3 +8,4 @@ export * from './pluralize'; export * from './case'; export * from './naming'; +export * from './transform-keys'; diff --git a/packages/inflekt/src/transform-keys.ts b/packages/inflekt/src/transform-keys.ts new file mode 100644 index 0000000..af4da18 --- /dev/null +++ b/packages/inflekt/src/transform-keys.ts @@ -0,0 +1,88 @@ +/** + * Deep object key transformation utilities + * + * Transform all property names (keys) in an object tree using pluggable inflekt transformers. + */ + +export type KeyTransformer = (key: string) => string; + +export interface InflektTreeOptions { + /** + * Optional function to skip transformation for specific keys + * @param key - The current key being processed + * @param path - Array of keys representing the path to the current key + * @returns true to skip transformation, false to transform + */ + skip?: (key: string, path: string[]) => boolean; +} + +/** + * Recursively traverse an object and transform all property names using the provided transformer. + * + * @param obj - The object to transform + * @param transformer - Function that transforms a key string + * @param options - Optional configuration + * @returns A new object with transformed keys + * + * @example + * // Convert snake_case keys to camelCase + * const apiResponse = { user_name: 'John', order_items: [{ item_id: 1 }] }; + * const result = inflektTree(apiResponse, (key) => camelize(key, true)); + * // Result: { userName: 'John', orderItems: [{ itemId: 1 }] } + * + * @example + * // Convert camelCase keys to snake_case + * const frontendObj = { userName: 'John', orderItems: [{ itemId: 1 }] }; + * const result = inflektTree(frontendObj, underscore); + * // Result: { user_name: 'John', order_items: [{ item_id: 1 }] } + * + * @example + * // Skip keys starting with underscore + * inflektTree(obj, (key) => camelize(key, true), { + * skip: (key) => key.startsWith('_') + * }); + */ +export function inflektTree( + obj: any, + transformer: KeyTransformer, + options?: InflektTreeOptions +): any { + return transformKeys(obj, transformer, options, []); +} + +function transformKeys( + obj: any, + transformer: KeyTransformer, + options: InflektTreeOptions | undefined, + path: string[] +): any { + // Handle primitives (null, undefined, non-objects) + if (obj == null || typeof obj !== 'object') { + return obj; + } + + // Handle Date - clone and return + if (obj instanceof Date) { + return new Date(obj.getTime()); + } + + // Handle Array - recursively transform each element + if (Array.isArray(obj)) { + return obj.map((item, index) => + transformKeys(item, transformer, options, path) + ); + } + + // Handle Object - create new object with transformed keys + const result: Record = {}; + + for (const key of Object.keys(obj)) { + const shouldSkip = options?.skip?.(key, path) ?? false; + const newKey = shouldSkip ? key : transformer(key); + const newPath = [...path, key]; + + result[newKey] = transformKeys(obj[key], transformer, options, newPath); + } + + return result; +}