Skip to content

Commit c80de34

Browse files
authored
Merge pull request #71 from constructive-io/feat/inflekt-shared-naming-utils
feat(inflekt): add shared name-matching utilities and case helpers
2 parents 276d212 + a81bf53 commit c80de34

5 files changed

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

0 commit comments

Comments
 (0)