Skip to content

Commit 3dce373

Browse files
committed
feat: add @interweb/inflection library for PostGraphile-compatible pluralization
1 parent f5f8bea commit 3dce373

11 files changed

Lines changed: 3149 additions & 5225 deletions

File tree

packages/inflection/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# @interweb/inflection
2+
3+
Inflection utilities for pluralization and singularization with PostGraphile-compatible Latin suffix handling.
4+
5+
## Installation
6+
7+
```bash
8+
npm install @interweb/inflection
9+
```
10+
11+
## Usage
12+
13+
```typescript
14+
import {
15+
singularize,
16+
pluralize,
17+
singularizeLast,
18+
pluralizeLast,
19+
distinctPluralize,
20+
lcFirst,
21+
ucFirst,
22+
toFieldName,
23+
toQueryName,
24+
} from '@interweb/inflection';
25+
26+
// Basic singularization/pluralization
27+
singularize('Users'); // 'User'
28+
pluralize('User'); // 'Users'
29+
30+
// Latin suffix handling (PostGraphile-compatible)
31+
singularize('Schemata'); // 'Schema' (not 'Schematum')
32+
singularize('Criteria'); // 'Criterion'
33+
singularize('Media'); // 'Medium'
34+
35+
// Compound word handling (only transforms last word)
36+
singularizeLast('UserProfiles'); // 'UserProfile'
37+
pluralizeLast('UserProfile'); // 'UserProfiles'
38+
39+
// Case transformations
40+
lcFirst('UserProfile'); // 'userProfile'
41+
ucFirst('userProfile'); // 'UserProfile'
42+
43+
// GraphQL naming helpers
44+
toFieldName('Users'); // 'user'
45+
toQueryName('User'); // 'users'
46+
```
47+
48+
## API
49+
50+
### Pluralization
51+
52+
- `singularize(word)` - Convert a word to singular form with Latin suffix handling
53+
- `pluralize(word)` - Convert a word to plural form
54+
- `singularizeLast(str)` - Singularize only the last word in a compound name
55+
- `pluralizeLast(str)` - Pluralize only the last word in a compound name
56+
- `distinctPluralize(str)` - Create a distinct plural form (handles cases where singular === plural)
57+
- `distinctPluralizeLast(str)` - Distinctly pluralize only the last word
58+
59+
### Case Transformations
60+
61+
- `lcFirst(str)` - Convert first character to lowercase (PascalCase to camelCase)
62+
- `ucFirst(str)` - Convert first character to uppercase (camelCase to PascalCase)
63+
- `fixCapitalisedPlural(str)` - Fix capitalized S after numbers (e.g., `Table1S` -> `Table1s`)
64+
65+
### Naming Helpers
66+
67+
- `toFieldName(pluralTypeName)` - Convert plural PascalCase to singular camelCase field name
68+
- `toQueryName(singularTypeName)` - Convert singular PascalCase to plural camelCase query name
69+
70+
## Latin Suffix Overrides
71+
72+
This library handles Latin plural suffixes differently than the standard `inflection` package to match PostGraphile's behavior:
73+
74+
| Plural | Singular |
75+
|--------|----------|
76+
| schemata | schema |
77+
| criteria | criterion |
78+
| phenomena | phenomenon |
79+
| media | medium |
80+
| memoranda | memorandum |
81+
| strata | stratum |
82+
| curricula | curriculum |
83+
| data | datum |
84+
85+
## License
86+
87+
MIT
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import {
2+
singularize,
3+
pluralize,
4+
singularizeLast,
5+
pluralizeLast,
6+
distinctPluralize,
7+
distinctPluralizeLast,
8+
lcFirst,
9+
ucFirst,
10+
fixCapitalisedPlural,
11+
toFieldName,
12+
toQueryName,
13+
} from '../src';
14+
15+
describe('singularize', () => {
16+
it('should singularize regular words', () => {
17+
expect(singularize('Users')).toBe('User');
18+
expect(singularize('users')).toBe('user');
19+
expect(singularize('People')).toBe('Person');
20+
expect(singularize('people')).toBe('person');
21+
expect(singularize('Categories')).toBe('Category');
22+
});
23+
24+
it('should handle Latin suffix overrides', () => {
25+
expect(singularize('Schemata')).toBe('Schema');
26+
expect(singularize('schemata')).toBe('schema');
27+
expect(singularize('Criteria')).toBe('Criterion');
28+
expect(singularize('criteria')).toBe('criterion');
29+
expect(singularize('Phenomena')).toBe('Phenomenon');
30+
expect(singularize('Media')).toBe('Medium');
31+
expect(singularize('Memoranda')).toBe('Memorandum');
32+
expect(singularize('Strata')).toBe('Stratum');
33+
expect(singularize('Curricula')).toBe('Curriculum');
34+
expect(singularize('Data')).toBe('Datum');
35+
});
36+
37+
it('should handle compound words with Latin suffixes', () => {
38+
expect(singularize('ApiSchemata')).toBe('ApiSchema');
39+
expect(singularize('UserMedia')).toBe('UserMedium');
40+
expect(singularize('TestCriteria')).toBe('TestCriterion');
41+
});
42+
43+
it('should preserve case of suffix', () => {
44+
expect(singularize('apiSchemata')).toBe('apiSchema');
45+
expect(singularize('SCHEMATA')).toBe('SCHEMA');
46+
});
47+
});
48+
49+
describe('pluralize', () => {
50+
it('should pluralize regular words', () => {
51+
expect(pluralize('User')).toBe('Users');
52+
expect(pluralize('user')).toBe('users');
53+
expect(pluralize('Person')).toBe('People');
54+
expect(pluralize('Category')).toBe('Categories');
55+
});
56+
});
57+
58+
describe('singularizeLast', () => {
59+
it('should singularize only the last word in compound names', () => {
60+
expect(singularizeLast('user_profiles')).toBe('user_profile');
61+
expect(singularizeLast('UserProfiles')).toBe('UserProfile');
62+
expect(singularizeLast('order_items')).toBe('order_item');
63+
expect(singularizeLast('OrderItems')).toBe('OrderItem');
64+
});
65+
66+
it('should handle Latin suffixes in compound names', () => {
67+
expect(singularizeLast('api_schemata')).toBe('api_schema');
68+
expect(singularizeLast('ApiSchemata')).toBe('ApiSchema');
69+
});
70+
});
71+
72+
describe('pluralizeLast', () => {
73+
it('should pluralize only the last word in compound names', () => {
74+
expect(pluralizeLast('user_profile')).toBe('user_profiles');
75+
expect(pluralizeLast('UserProfile')).toBe('UserProfiles');
76+
expect(pluralizeLast('order_item')).toBe('order_items');
77+
expect(pluralizeLast('OrderItem')).toBe('OrderItems');
78+
});
79+
});
80+
81+
describe('distinctPluralize', () => {
82+
it('should pluralize regular words', () => {
83+
expect(distinctPluralize('user')).toBe('users');
84+
expect(distinctPluralize('User')).toBe('Users');
85+
});
86+
87+
it('should handle words where singular equals plural', () => {
88+
expect(distinctPluralize('sheep')).toBe('sheeps');
89+
expect(distinctPluralize('fish')).toBe('fishes');
90+
});
91+
92+
it('should handle words ending in ch, s, sh, x, z', () => {
93+
expect(distinctPluralize('bus')).toBe('buses');
94+
expect(distinctPluralize('box')).toBe('boxes');
95+
});
96+
});
97+
98+
describe('distinctPluralizeLast', () => {
99+
it('should distinctly pluralize only the last word', () => {
100+
expect(distinctPluralizeLast('user_profile')).toBe('user_profiles');
101+
expect(distinctPluralizeLast('UserProfile')).toBe('UserProfiles');
102+
});
103+
});
104+
105+
describe('lcFirst', () => {
106+
it('should lowercase the first character', () => {
107+
expect(lcFirst('UserProfile')).toBe('userProfile');
108+
expect(lcFirst('User')).toBe('user');
109+
expect(lcFirst('ABC')).toBe('aBC');
110+
});
111+
112+
it('should handle already lowercase strings', () => {
113+
expect(lcFirst('user')).toBe('user');
114+
});
115+
});
116+
117+
describe('ucFirst', () => {
118+
it('should uppercase the first character', () => {
119+
expect(ucFirst('userProfile')).toBe('UserProfile');
120+
expect(ucFirst('user')).toBe('User');
121+
expect(ucFirst('abc')).toBe('Abc');
122+
});
123+
124+
it('should handle already uppercase strings', () => {
125+
expect(ucFirst('User')).toBe('User');
126+
});
127+
});
128+
129+
describe('fixCapitalisedPlural', () => {
130+
it('should fix capitalized S after numbers', () => {
131+
expect(fixCapitalisedPlural('Table1S')).toBe('Table1s');
132+
expect(fixCapitalisedPlural('blahTable1S')).toBe('blahTable1s');
133+
expect(fixCapitalisedPlural('Table1SConnection')).toBe('Table1sConnection');
134+
});
135+
136+
it('should not affect normal strings', () => {
137+
expect(fixCapitalisedPlural('Users')).toBe('Users');
138+
expect(fixCapitalisedPlural('Table1')).toBe('Table1');
139+
});
140+
});
141+
142+
describe('toFieldName', () => {
143+
it('should convert plural PascalCase to singular camelCase', () => {
144+
expect(toFieldName('Users')).toBe('user');
145+
expect(toFieldName('OrderItems')).toBe('orderItem');
146+
expect(toFieldName('Categories')).toBe('category');
147+
});
148+
149+
it('should handle Latin suffixes', () => {
150+
expect(toFieldName('Schemata')).toBe('schema');
151+
expect(toFieldName('ApiSchemata')).toBe('apiSchema');
152+
});
153+
});
154+
155+
describe('toQueryName', () => {
156+
it('should convert singular PascalCase to plural camelCase', () => {
157+
expect(toQueryName('User')).toBe('users');
158+
expect(toQueryName('OrderItem')).toBe('orderItems');
159+
expect(toQueryName('Category')).toBe('categories');
160+
});
161+
});

packages/inflection/jest.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
transform: {
6+
'^.+\\.tsx?$': [
7+
'ts-jest',
8+
{
9+
babelConfig: false,
10+
tsconfig: 'tsconfig.json',
11+
},
12+
],
13+
},
14+
transformIgnorePatterns: [`/node_modules/*`],
15+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
16+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
17+
modulePathIgnorePatterns: ['dist/*']
18+
};

packages/inflection/package.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "@interweb/inflection",
3+
"version": "0.0.1",
4+
"description": "Inflection utilities for pluralization and singularization with PostGraphile-compatible Latin suffix handling",
5+
"author": "Constructive <developers@constructive.io>",
6+
"homepage": "https://github.com/constructive-io/dev-utils",
7+
"license": "MIT",
8+
"main": "index.js",
9+
"module": "esm/index.js",
10+
"types": "index.d.ts",
11+
"publishConfig": {
12+
"access": "public",
13+
"directory": "dist"
14+
},
15+
"scripts": {
16+
"copy": "makage assets",
17+
"clean": "makage clean",
18+
"prepublishOnly": "npm run build",
19+
"build": "makage build",
20+
"lint": "eslint . --fix",
21+
"test": "jest",
22+
"test:watch": "jest --watch"
23+
},
24+
"repository": {
25+
"type": "git",
26+
"url": "https://github.com/constructive-io/dev-utils"
27+
},
28+
"keywords": [
29+
"inflection",
30+
"pluralize",
31+
"singularize",
32+
"postgraphile",
33+
"graphql",
34+
"constructive"
35+
],
36+
"bugs": {
37+
"url": "https://github.com/constructive-io/dev-utils/issues"
38+
},
39+
"devDependencies": {
40+
"makage": "0.1.8"
41+
},
42+
"dependencies": {
43+
"inflection": "^3.0.0"
44+
}
45+
}

packages/inflection/src/case.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Case transformation utilities
3+
*/
4+
5+
/**
6+
* Convert PascalCase to camelCase (lowercase first character)
7+
* @example "UserProfile" -> "userProfile"
8+
*/
9+
export function lcFirst(str: string): string {
10+
return str.charAt(0).toLowerCase() + str.slice(1);
11+
}
12+
13+
/**
14+
* Convert camelCase to PascalCase (uppercase first character)
15+
* @example "userProfile" -> "UserProfile"
16+
*/
17+
export function ucFirst(str: string): string {
18+
return str.charAt(0).toUpperCase() + str.slice(1);
19+
}
20+
21+
/**
22+
* Fix capitalized plurals that end with a number followed by 'S'
23+
* This solves the issue with `blah-table1s` becoming `blahTable1S`
24+
* @example "Table1S" -> "Table1s"
25+
*/
26+
export function fixCapitalisedPlural(str: string): string {
27+
return str.replace(/[0-9]S(?=[A-Z]|$)/g, (match) => match.toLowerCase());
28+
}

packages/inflection/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Inflection utilities for pluralization and singularization
3+
*
4+
* This library provides consistent inflection behavior for PostGraphile and GraphQL codegen.
5+
* It uses the 'inflection' package with custom overrides for Latin plural suffixes
6+
* that PostGraphile handles differently than standard English pluralization.
7+
*/
8+
export * from './pluralize';
9+
export * from './case';
10+
export * from './naming';

packages/inflection/src/naming.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Naming utilities for GraphQL field and query name generation
3+
*/
4+
import { lcFirst } from './case';
5+
import { pluralize, singularize } from './pluralize';
6+
7+
/**
8+
* Convert a plural PascalCase type name to singular camelCase field name
9+
* @example "Users" -> "user", "OrderItems" -> "orderItem"
10+
*/
11+
export function toFieldName(pluralTypeName: string): string {
12+
return lcFirst(singularize(pluralTypeName));
13+
}
14+
15+
/**
16+
* Convert a singular PascalCase type name to plural camelCase query name
17+
* @example "User" -> "users", "OrderItem" -> "orderItems"
18+
*/
19+
export function toQueryName(singularTypeName: string): string {
20+
return lcFirst(pluralize(singularTypeName));
21+
}

0 commit comments

Comments
 (0)