diff --git a/packages/inflekt/__tests__/matching.test.ts b/packages/inflekt/__tests__/matching.test.ts new file mode 100644 index 0000000..43f8c2e --- /dev/null +++ b/packages/inflekt/__tests__/matching.test.ts @@ -0,0 +1,139 @@ +import { + normalizeName, + normalizeNameSingular, + fuzzyFindByName, + namesMatch, +} from '../src'; + +describe('normalizeName', () => { + it('should lowercase and strip underscores', () => { + expect(normalizeName('delivery_zone')).toBe('deliveryzone'); + expect(normalizeName('DeliveryZone')).toBe('deliveryzone'); + expect(normalizeName('deliveryZones')).toBe('deliveryzones'); + expect(normalizeName('DELIVERY_ZONE')).toBe('deliveryzone'); + expect(normalizeName('shipments')).toBe('shipments'); + expect(normalizeName('Shipment')).toBe('shipment'); + }); +}); + +describe('normalizeNameSingular', () => { + it('should normalize and strip trailing s', () => { + expect(normalizeNameSingular('shipments')).toBe('shipment'); + expect(normalizeNameSingular('routes')).toBe('route'); + expect(normalizeNameSingular('deliveryZones')).toBe('deliveryzone'); + expect(normalizeNameSingular('delivery_zones')).toBe('deliveryzone'); + }); + + it('should not strip s from names that do not end in s', () => { + expect(normalizeNameSingular('DeliveryZone')).toBe('deliveryzone'); + expect(normalizeNameSingular('Shipment')).toBe('shipment'); + expect(normalizeNameSingular('Route')).toBe('route'); + }); +}); + +describe('fuzzyFindByName', () => { + const tables = [ + { name: 'Shipment' }, + { name: 'DeliveryZone' }, + { name: 'Route' }, + { name: 'DriverVehicleAssignment' }, + ]; + + it('should match exact names', () => { + expect(fuzzyFindByName(tables, 'Shipment', (t) => t.name)).toEqual({ + name: 'Shipment', + }); + expect(fuzzyFindByName(tables, 'Route', (t) => t.name)).toEqual({ + name: 'Route', + }); + }); + + it('should match snake_case codec names to PascalCase table names', () => { + expect(fuzzyFindByName(tables, 'delivery_zone', (t) => t.name)).toEqual({ + name: 'DeliveryZone', + }); + expect( + fuzzyFindByName(tables, 'driver_vehicle_assignments', (t) => t.name), + ).toEqual({ name: 'DriverVehicleAssignment' }); + }); + + it('should match plural camelCase codec names to PascalCase table names', () => { + expect(fuzzyFindByName(tables, 'shipments', (t) => t.name)).toEqual({ + name: 'Shipment', + }); + expect(fuzzyFindByName(tables, 'routes', (t) => t.name)).toEqual({ + name: 'Route', + }); + expect( + fuzzyFindByName(tables, 'driverVehicleAssignments', (t) => t.name), + ).toEqual({ name: 'DriverVehicleAssignment' }); + }); + + it('should return undefined for no match', () => { + expect(fuzzyFindByName(tables, 'NonExistent', (t) => t.name)).toBeUndefined(); + expect(fuzzyFindByName(tables, 'zzz', (t) => t.name)).toBeUndefined(); + }); + + it('should prefer exact match over fuzzy match', () => { + const items = [{ name: 'routes' }, { name: 'Route' }]; + expect(fuzzyFindByName(items, 'routes', (t) => t.name)).toEqual({ + name: 'routes', + }); + expect(fuzzyFindByName(items, 'Route', (t) => t.name)).toEqual({ + name: 'Route', + }); + }); +}); + +describe('namesMatch', () => { + it('should match identical names', () => { + expect(namesMatch('Shipment', 'Shipment')).toBe(true); + }); + + it('should match case-insensitive names', () => { + expect(namesMatch('shipment', 'Shipment')).toBe(true); + expect(namesMatch('ROUTE', 'route')).toBe(true); + }); + + it('should match snake_case to PascalCase', () => { + expect(namesMatch('delivery_zone', 'DeliveryZone')).toBe(true); + }); + + it('should match plural to singular', () => { + expect(namesMatch('shipments', 'Shipment')).toBe(true); + expect(namesMatch('routes', 'Route')).toBe(true); + }); + + it('should not match unrelated names', () => { + expect(namesMatch('User', 'Post')).toBe(false); + expect(namesMatch('Route', 'DeliveryZone')).toBe(false); + }); +}); + +describe('case helpers: toCamelCase, toPascalCase, toScreamingSnake', () => { + // Import from index to verify they're exported + const { + toCamelCase, + toPascalCase, + toScreamingSnake, + } = require('../src'); + + it('toCamelCase should convert hyphenated and underscored strings', () => { + expect(toCamelCase('user-profile')).toBe('userProfile'); + expect(toCamelCase('user_profile')).toBe('userProfile'); + expect(toCamelCase('UserProfile')).toBe('userProfile'); + expect(toCamelCase('some-long-name')).toBe('someLongName'); + }); + + it('toPascalCase should convert hyphenated and underscored strings', () => { + expect(toPascalCase('user-profile')).toBe('UserProfile'); + expect(toPascalCase('user_profile')).toBe('UserProfile'); + expect(toPascalCase('userProfile')).toBe('UserProfile'); + }); + + it('toScreamingSnake should convert camelCase and PascalCase', () => { + expect(toScreamingSnake('userProfile')).toBe('USER_PROFILE'); + expect(toScreamingSnake('UserProfile')).toBe('USER_PROFILE'); + expect(toScreamingSnake('displayName')).toBe('DISPLAY_NAME'); + }); +}); diff --git a/packages/inflekt/package.json b/packages/inflekt/package.json index 5f9366e..8ef68c9 100644 --- a/packages/inflekt/package.json +++ b/packages/inflekt/package.json @@ -1,6 +1,6 @@ { "name": "inflekt", - "version": "0.3.3", + "version": "0.4.0", "description": "Inflection utilities for pluralization and singularization with PostGraphile-compatible Latin suffix handling", "author": "Constructive ", "homepage": "https://github.com/constructive-io/dev-utils", diff --git a/packages/inflekt/src/case.ts b/packages/inflekt/src/case.ts index 9bc7c13..def6adf 100644 --- a/packages/inflekt/src/case.ts +++ b/packages/inflekt/src/case.ts @@ -57,3 +57,41 @@ export function underscore(str: string): string { .replace(/^_/, '') .toLowerCase(); } + +/** + * Convert a hyphenated or underscored string to camelCase. + * Unlike `camelize`, this also handles hyphens and preserves + * camelCase boundaries that are already present. + * @example toCamelCase('user-profile') -> 'userProfile' + * @example toCamelCase('user_profile') -> 'userProfile' + */ +export function toCamelCase(str: string): string { + return str + .replace(/[-_](.)/g, (_, char) => char.toUpperCase()) + .replace(/^(.)/, (_, char) => char.toLowerCase()); +} + +/** + * Convert a hyphenated or underscored string to PascalCase. + * Unlike `camelize`, this also handles hyphens. + * @example toPascalCase('user-profile') -> 'UserProfile' + * @example toPascalCase('user_profile') -> 'UserProfile' + */ +export function toPascalCase(str: string): string { + return str + .replace(/[-_](.)/g, (_, char) => char.toUpperCase()) + .replace(/^(.)/, (_, char) => char.toUpperCase()); +} + +/** + * Convert a camelCase or PascalCase string to SCREAMING_SNAKE_CASE. + * @example toScreamingSnake('userProfile') -> 'USER_PROFILE' + * @example toScreamingSnake('UserProfile') -> 'USER_PROFILE' + */ +export function toScreamingSnake(str: string): string { + return str + .replace(/([A-Z])/g, '_$1') + .replace(/[-\s]/g, '_') + .toUpperCase() + .replace(/^_/, ''); +} diff --git a/packages/inflekt/src/index.ts b/packages/inflekt/src/index.ts index d9d12e4..7a65ef3 100644 --- a/packages/inflekt/src/index.ts +++ b/packages/inflekt/src/index.ts @@ -8,4 +8,5 @@ export * from './pluralize'; export * from './case'; export * from './naming'; +export * from './matching'; export * from './transform-keys'; diff --git a/packages/inflekt/src/matching.ts b/packages/inflekt/src/matching.ts new file mode 100644 index 0000000..7cc94d5 --- /dev/null +++ b/packages/inflekt/src/matching.ts @@ -0,0 +1,95 @@ +/** + * Name matching utilities for resolving PostGraphile v5 naming mismatches. + * + * PostGraphile v5 uses different inflection conventions in different contexts: + * - Table types are PascalCase (e.g., "Shipment", "DeliveryZone") + * - Relation codec names are raw snake_case or camelCase (e.g., "shipments", "deliveryZones") + * + * These helpers provide a single, shared way to normalize and compare names + * across those boundaries instead of duplicating fuzzy-match logic in every consumer. + */ + +/** + * Normalize a name for case-insensitive, delimiter-insensitive comparison. + * Strips underscores and lowercases. + * + * @example normalizeName("delivery_zone") // "deliveryzone" + * @example normalizeName("DeliveryZone") // "deliveryzone" + * @example normalizeName("deliveryZones") // "deliveryzones" + */ +export function normalizeName(name: string): string { + return name.toLowerCase().replace(/_/g, ''); +} + +/** + * Normalize a name to its singular base form for comparison. + * Strips underscores, lowercases, and removes a trailing 's' when present. + * + * @example normalizeNameSingular("shipments") // "shipment" + * @example normalizeNameSingular("DeliveryZone") // "deliveryzone" + * @example normalizeNameSingular("routes") // "route" + */ +export function normalizeNameSingular(name: string): string { + const normalized = normalizeName(name); + return normalized.endsWith('s') ? normalized.slice(0, -1) : normalized; +} + +/** + * Find a matching item by name using exact match first, then fuzzy + * case-insensitive / plural-insensitive fallback. + * + * This is the single shared implementation for resolving relation target names + * to table definitions, replacing ad-hoc fuzzy matching scattered across consumers. + * + * @param items - Array of items to search through + * @param targetName - The name to find (may be PascalCase, snake_case, plural, etc.) + * @param getName - Accessor to extract the comparable name from each item + * @returns The matched item, or undefined if no match found + * + * @example + * // Find a table by its relation target name + * const table = fuzzyFindByName(allTables, "shipments", t => t.name); + * // Matches Table with name "Shipment" + * + * @example + * // Works with snake_case codec names too + * const table = fuzzyFindByName(allTables, "delivery_zone", t => t.name); + * // Matches Table with name "DeliveryZone" + */ +export function fuzzyFindByName( + items: T[], + targetName: string, + getName: (item: T) => string, +): T | undefined { + // 1. Exact match (fast path) + const exact = items.find((item) => getName(item) === targetName); + if (exact) return exact; + + // 2. Fuzzy match: case-insensitive, strip underscores, optional trailing 's' + const targetNormalized = normalizeName(targetName); + const targetBase = normalizeNameSingular(targetName); + + return items.find((item) => { + const itemNormalized = normalizeName(getName(item)); + return itemNormalized === targetNormalized || itemNormalized === targetBase; + }); +} + +/** + * Check whether two names refer to the same entity, ignoring case, + * underscores, and singular/plural differences. + * + * @example namesMatch("shipments", "Shipment") // true + * @example namesMatch("delivery_zone", "DeliveryZone") // true + * @example namesMatch("Route", "routes") // true + * @example namesMatch("User", "Post") // false + */ +export function namesMatch(a: string, b: string): boolean { + if (a === b) return true; + const aNorm = normalizeName(a); + const bNorm = normalizeName(b); + if (aNorm === bNorm) return true; + const aBase = normalizeNameSingular(a); + const bBase = normalizeNameSingular(b); + return aBase === bBase; +}