Skip to content
Merged
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
139 changes: 139 additions & 0 deletions packages/inflekt/__tests__/matching.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion packages/inflekt/package.json
Original file line number Diff line number Diff line change
@@ -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 <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/dev-utils",
Expand Down
38 changes: 38 additions & 0 deletions packages/inflekt/src/case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^_/, '');
}
1 change: 1 addition & 0 deletions packages/inflekt/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
export * from './pluralize';
export * from './case';
export * from './naming';
export * from './matching';
export * from './transform-keys';
95 changes: 95 additions & 0 deletions packages/inflekt/src/matching.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
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;
}
Loading