Skip to content

Commit 650d12e

Browse files
committed
feat(inflekt): add shared name-matching utilities and case helpers
- Add matching.ts with fuzzyFindByName, namesMatch, normalizeName, normalizeNameSingular - Add toCamelCase, toPascalCase, toScreamingSnake to case.ts - Export new matching module from index - Add comprehensive tests for all new functions - Bump version to 0.4.0
1 parent 276d212 commit 650d12e

5 files changed

Lines changed: 276 additions & 1 deletion

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {
2+
normalizeName,
3+
normalizeNameSingular,
4+
fuzzyFindByName,
5+
namesMatch,
6+
} from '../src';
7+
8+
describe('normalizeName', () => {
9+
it('should lowercase and strip underscores', () => {
10+
expect(normalizeName('delivery_zone')).toBe('deliveryzone');
11+
expect(normalizeName('DeliveryZone')).toBe('deliveryzone');
12+
expect(normalizeName('deliveryZones')).toBe('deliveryzones');
13+
expect(normalizeName('DELIVERY_ZONE')).toBe('deliveryzone');
14+
expect(normalizeName('shipments')).toBe('shipments');
15+
expect(normalizeName('Shipment')).toBe('shipment');
16+
});
17+
});
18+
19+
describe('normalizeNameSingular', () => {
20+
it('should normalize and strip trailing s', () => {
21+
expect(normalizeNameSingular('shipments')).toBe('shipment');
22+
expect(normalizeNameSingular('routes')).toBe('route');
23+
expect(normalizeNameSingular('deliveryZones')).toBe('deliveryzone');
24+
expect(normalizeNameSingular('delivery_zones')).toBe('deliveryzone');
25+
});
26+
27+
it('should not strip s from names that do not end in s', () => {
28+
expect(normalizeNameSingular('DeliveryZone')).toBe('deliveryzone');
29+
expect(normalizeNameSingular('Shipment')).toBe('shipment');
30+
expect(normalizeNameSingular('Route')).toBe('route');
31+
});
32+
});
33+
34+
describe('fuzzyFindByName', () => {
35+
const tables = [
36+
{ name: 'Shipment' },
37+
{ name: 'DeliveryZone' },
38+
{ name: 'Route' },
39+
{ name: 'DriverVehicleAssignment' },
40+
];
41+
42+
it('should match exact names', () => {
43+
expect(fuzzyFindByName(tables, 'Shipment', (t) => t.name)).toEqual({
44+
name: 'Shipment',
45+
});
46+
expect(fuzzyFindByName(tables, 'Route', (t) => t.name)).toEqual({
47+
name: 'Route',
48+
});
49+
});
50+
51+
it('should match snake_case codec names to PascalCase table names', () => {
52+
expect(fuzzyFindByName(tables, 'delivery_zone', (t) => t.name)).toEqual({
53+
name: 'DeliveryZone',
54+
});
55+
expect(
56+
fuzzyFindByName(tables, 'driver_vehicle_assignments', (t) => t.name),
57+
).toEqual({ name: 'DriverVehicleAssignment' });
58+
});
59+
60+
it('should match plural camelCase codec names to PascalCase table names', () => {
61+
expect(fuzzyFindByName(tables, 'shipments', (t) => t.name)).toEqual({
62+
name: 'Shipment',
63+
});
64+
expect(fuzzyFindByName(tables, 'routes', (t) => t.name)).toEqual({
65+
name: 'Route',
66+
});
67+
expect(
68+
fuzzyFindByName(tables, 'driverVehicleAssignments', (t) => t.name),
69+
).toEqual({ name: 'DriverVehicleAssignment' });
70+
});
71+
72+
it('should return undefined for no match', () => {
73+
expect(fuzzyFindByName(tables, 'NonExistent', (t) => t.name)).toBeUndefined();
74+
expect(fuzzyFindByName(tables, 'zzz', (t) => t.name)).toBeUndefined();
75+
});
76+
77+
it('should prefer exact match over fuzzy match', () => {
78+
const items = [{ name: 'routes' }, { name: 'Route' }];
79+
expect(fuzzyFindByName(items, 'routes', (t) => t.name)).toEqual({
80+
name: 'routes',
81+
});
82+
expect(fuzzyFindByName(items, 'Route', (t) => t.name)).toEqual({
83+
name: 'Route',
84+
});
85+
});
86+
});
87+
88+
describe('namesMatch', () => {
89+
it('should match identical names', () => {
90+
expect(namesMatch('Shipment', 'Shipment')).toBe(true);
91+
});
92+
93+
it('should match case-insensitive names', () => {
94+
expect(namesMatch('shipment', 'Shipment')).toBe(true);
95+
expect(namesMatch('ROUTE', 'route')).toBe(true);
96+
});
97+
98+
it('should match snake_case to PascalCase', () => {
99+
expect(namesMatch('delivery_zone', 'DeliveryZone')).toBe(true);
100+
});
101+
102+
it('should match plural to singular', () => {
103+
expect(namesMatch('shipments', 'Shipment')).toBe(true);
104+
expect(namesMatch('routes', 'Route')).toBe(true);
105+
});
106+
107+
it('should not match unrelated names', () => {
108+
expect(namesMatch('User', 'Post')).toBe(false);
109+
expect(namesMatch('Route', 'DeliveryZone')).toBe(false);
110+
});
111+
});
112+
113+
describe('case helpers: toCamelCase, toPascalCase, toScreamingSnake', () => {
114+
// Import from index to verify they're exported
115+
const {
116+
toCamelCase,
117+
toPascalCase,
118+
toScreamingSnake,
119+
} = require('../src');
120+
121+
it('toCamelCase should convert hyphenated and underscored strings', () => {
122+
expect(toCamelCase('user-profile')).toBe('userProfile');
123+
expect(toCamelCase('user_profile')).toBe('userProfile');
124+
expect(toCamelCase('UserProfile')).toBe('userProfile');
125+
expect(toCamelCase('some-long-name')).toBe('someLongName');
126+
});
127+
128+
it('toPascalCase should convert hyphenated and underscored strings', () => {
129+
expect(toPascalCase('user-profile')).toBe('UserProfile');
130+
expect(toPascalCase('user_profile')).toBe('UserProfile');
131+
expect(toPascalCase('userProfile')).toBe('UserProfile');
132+
});
133+
134+
it('toScreamingSnake should convert camelCase and PascalCase', () => {
135+
expect(toScreamingSnake('userProfile')).toBe('USER_PROFILE');
136+
expect(toScreamingSnake('UserProfile')).toBe('USER_PROFILE');
137+
expect(toScreamingSnake('displayName')).toBe('DISPLAY_NAME');
138+
});
139+
});

packages/inflekt/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "inflekt",
3-
"version": "0.3.3",
3+
"version": "0.4.0",
44
"description": "Inflection utilities for pluralization and singularization with PostGraphile-compatible Latin suffix handling",
55
"author": "Constructive <developers@constructive.io>",
66
"homepage": "https://github.com/constructive-io/dev-utils",

packages/inflekt/src/case.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,41 @@ export function underscore(str: string): string {
5757
.replace(/^_/, '')
5858
.toLowerCase();
5959
}
60+
61+
/**
62+
* Convert a hyphenated or underscored string to camelCase.
63+
* Unlike `camelize`, this also handles hyphens and preserves
64+
* camelCase boundaries that are already present.
65+
* @example toCamelCase('user-profile') -> 'userProfile'
66+
* @example toCamelCase('user_profile') -> 'userProfile'
67+
*/
68+
export function toCamelCase(str: string): string {
69+
return str
70+
.replace(/[-_](.)/g, (_, char) => char.toUpperCase())
71+
.replace(/^(.)/, (_, char) => char.toLowerCase());
72+
}
73+
74+
/**
75+
* Convert a hyphenated or underscored string to PascalCase.
76+
* Unlike `camelize`, this also handles hyphens.
77+
* @example toPascalCase('user-profile') -> 'UserProfile'
78+
* @example toPascalCase('user_profile') -> 'UserProfile'
79+
*/
80+
export function toPascalCase(str: string): string {
81+
return str
82+
.replace(/[-_](.)/g, (_, char) => char.toUpperCase())
83+
.replace(/^(.)/, (_, char) => char.toUpperCase());
84+
}
85+
86+
/**
87+
* Convert a camelCase or PascalCase string to SCREAMING_SNAKE_CASE.
88+
* @example toScreamingSnake('userProfile') -> 'USER_PROFILE'
89+
* @example toScreamingSnake('UserProfile') -> 'USER_PROFILE'
90+
*/
91+
export function toScreamingSnake(str: string): string {
92+
return str
93+
.replace(/([A-Z])/g, '_$1')
94+
.replace(/[-\s]/g, '_')
95+
.toUpperCase()
96+
.replace(/^_/, '');
97+
}

packages/inflekt/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
export * from './pluralize';
99
export * from './case';
1010
export * from './naming';
11+
export * from './matching';
1112
export * from './transform-keys';

packages/inflekt/src/matching.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Name matching utilities for resolving PostGraphile v5 naming mismatches.
3+
*
4+
* PostGraphile v5 uses different inflection conventions in different contexts:
5+
* - Table types are PascalCase (e.g., "Shipment", "DeliveryZone")
6+
* - Relation codec names are raw snake_case or camelCase (e.g., "shipments", "deliveryZones")
7+
*
8+
* These helpers provide a single, shared way to normalize and compare names
9+
* across those boundaries instead of duplicating fuzzy-match logic in every consumer.
10+
*/
11+
12+
import { singularize } from './pluralize';
13+
14+
/**
15+
* Normalize a name for case-insensitive, delimiter-insensitive comparison.
16+
* Strips underscores and lowercases.
17+
*
18+
* @example normalizeName("delivery_zone") // "deliveryzone"
19+
* @example normalizeName("DeliveryZone") // "deliveryzone"
20+
* @example normalizeName("deliveryZones") // "deliveryzones"
21+
*/
22+
export function normalizeName(name: string): string {
23+
return name.toLowerCase().replace(/_/g, '');
24+
}
25+
26+
/**
27+
* Normalize a name to its singular base form for comparison.
28+
* Strips underscores, lowercases, and removes a trailing 's' when present.
29+
*
30+
* @example normalizeNameSingular("shipments") // "shipment"
31+
* @example normalizeNameSingular("DeliveryZone") // "deliveryzone"
32+
* @example normalizeNameSingular("routes") // "route"
33+
*/
34+
export function normalizeNameSingular(name: string): string {
35+
const normalized = normalizeName(name);
36+
return normalized.endsWith('s') ? normalized.slice(0, -1) : normalized;
37+
}
38+
39+
/**
40+
* Find a matching item by name using exact match first, then fuzzy
41+
* case-insensitive / plural-insensitive fallback.
42+
*
43+
* This is the single shared implementation for resolving relation target names
44+
* to table definitions, replacing ad-hoc fuzzy matching scattered across consumers.
45+
*
46+
* @param items - Array of items to search through
47+
* @param targetName - The name to find (may be PascalCase, snake_case, plural, etc.)
48+
* @param getName - Accessor to extract the comparable name from each item
49+
* @returns The matched item, or undefined if no match found
50+
*
51+
* @example
52+
* // Find a table by its relation target name
53+
* const table = fuzzyFindByName(allTables, "shipments", t => t.name);
54+
* // Matches Table with name "Shipment"
55+
*
56+
* @example
57+
* // Works with snake_case codec names too
58+
* const table = fuzzyFindByName(allTables, "delivery_zone", t => t.name);
59+
* // Matches Table with name "DeliveryZone"
60+
*/
61+
export function fuzzyFindByName<T>(
62+
items: T[],
63+
targetName: string,
64+
getName: (item: T) => string,
65+
): T | undefined {
66+
// 1. Exact match (fast path)
67+
const exact = items.find((item) => getName(item) === targetName);
68+
if (exact) return exact;
69+
70+
// 2. Fuzzy match: case-insensitive, strip underscores, optional trailing 's'
71+
const targetNormalized = normalizeName(targetName);
72+
const targetBase = normalizeNameSingular(targetName);
73+
74+
return items.find((item) => {
75+
const itemNormalized = normalizeName(getName(item));
76+
return itemNormalized === targetNormalized || itemNormalized === targetBase;
77+
});
78+
}
79+
80+
/**
81+
* Check whether two names refer to the same entity, ignoring case,
82+
* underscores, and singular/plural differences.
83+
*
84+
* @example namesMatch("shipments", "Shipment") // true
85+
* @example namesMatch("delivery_zone", "DeliveryZone") // true
86+
* @example namesMatch("Route", "routes") // true
87+
* @example namesMatch("User", "Post") // false
88+
*/
89+
export function namesMatch(a: string, b: string): boolean {
90+
if (a === b) return true;
91+
const aNorm = normalizeName(a);
92+
const bNorm = normalizeName(b);
93+
if (aNorm === bNorm) return true;
94+
const aBase = normalizeNameSingular(a);
95+
const bBase = normalizeNameSingular(b);
96+
return aBase === bBase;
97+
}

0 commit comments

Comments
 (0)