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
68 changes: 68 additions & 0 deletions packages/inflekt/__tests__/inflection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ describe('singularize', () => {
expect(singularize('apiSchemata')).toBe('apiSchema');
expect(singularize('SCHEMATA')).toBe('SCHEMA');
});

it('should canonicalize malformed trailing triple-s words', () => {
expect(singularize('classs')).toBe('class');
expect(singularize('Classs')).toBe('Class');
expect(singularize('hazardClasss')).toBe('hazardClass');
expect(singularize('hazardClassses')).toBe('hazardClass');
expect(singularize('CLASSS')).toBe('CLASS');
});
});

describe('pluralize', () => {
Expand All @@ -55,6 +63,36 @@ describe('pluralize', () => {
expect(pluralize('Person')).toBe('People');
expect(pluralize('Category')).toBe('Categories');
});

it('should normalize class variants', () => {
expect(pluralize('class')).toBe('classes');
expect(pluralize('Class')).toBe('Classes');
expect(pluralize('hazardClass')).toBe('hazardClasses');
expect(pluralize('HazardClass')).toBe('HazardClasses');
expect(pluralize('hazardClasss')).toBe('hazardClasses');
expect(pluralize('classs')).toBe('classes');
});

it('should preserve already-plural Latin words', () => {
expect(pluralize('Schemata')).toBe('Schemata');
expect(pluralize('schemata')).toBe('schemata');
});

it.each([
['class', 'classes'],
['glass', 'glasses'],
['boss', 'bosses'],
['process', 'processes'],
['address', 'addresses'],
['witness', 'witnesses'],
['abyss', 'abysses'],
])(
'should handle -ss noun roundtrip for %s -> %s',
(singularWord, pluralWord) => {
expect(pluralize(singularWord)).toBe(pluralWord);
expect(singularize(pluralWord)).toBe(singularWord);
}
);
});

describe('singularizeLast', () => {
Expand All @@ -69,6 +107,11 @@ describe('singularizeLast', () => {
expect(singularizeLast('api_schemata')).toBe('api_schema');
expect(singularizeLast('ApiSchemata')).toBe('ApiSchema');
});

it('should normalize malformed class suffixes in the final segment', () => {
expect(singularizeLast('hazardClassses')).toBe('hazardClass');
expect(singularizeLast('HazardClassses')).toBe('HazardClass');
});
});

describe('pluralizeLast', () => {
Expand All @@ -78,6 +121,13 @@ describe('pluralizeLast', () => {
expect(pluralizeLast('order_item')).toBe('order_items');
expect(pluralizeLast('OrderItem')).toBe('OrderItems');
});

it('should normalize malformed class suffixes in the final segment', () => {
expect(pluralizeLast('hazardClass')).toBe('hazardClasses');
expect(pluralizeLast('HazardClass')).toBe('HazardClasses');
expect(pluralizeLast('hazardClasss')).toBe('hazardClasses');
expect(pluralizeLast('HazardClasss')).toBe('HazardClasses');
});
});

describe('distinctPluralize', () => {
Expand All @@ -95,13 +145,23 @@ describe('distinctPluralize', () => {
expect(distinctPluralize('bus')).toBe('buses');
expect(distinctPluralize('box')).toBe('boxes');
});

it('should normalize malformed class variants', () => {
expect(distinctPluralize('classs')).toBe('classes');
expect(distinctPluralize('hazardClasss')).toBe('hazardClasses');
});
});

describe('distinctPluralizeLast', () => {
it('should distinctly pluralize only the last word', () => {
expect(distinctPluralizeLast('user_profile')).toBe('user_profiles');
expect(distinctPluralizeLast('UserProfile')).toBe('UserProfiles');
});

it('should normalize malformed class variants in the final segment', () => {
expect(distinctPluralizeLast('hazardClasss')).toBe('hazardClasses');
expect(distinctPluralizeLast('HazardClasss')).toBe('HazardClasses');
});
});

describe('lcFirst', () => {
Expand Down Expand Up @@ -189,12 +249,20 @@ describe('toFieldName', () => {
expect(toFieldName('Schemata')).toBe('schema');
expect(toFieldName('ApiSchemata')).toBe('apiSchema');
});

it('should normalize malformed class variants', () => {
expect(toFieldName('Classes')).toBe('class');
expect(toFieldName('HazardClasses')).toBe('hazardClass');
expect(toFieldName('HazardClassses')).toBe('hazardClass');
});
});

describe('toQueryName', () => {
it('should convert singular PascalCase to plural camelCase', () => {
expect(toQueryName('User')).toBe('users');
expect(toQueryName('OrderItem')).toBe('orderItems');
expect(toQueryName('Category')).toBe('categories');
expect(toQueryName('Class')).toBe('classes');
expect(toQueryName('HazardClass')).toBe('hazardClasses');
});
});
59 changes: 53 additions & 6 deletions packages/inflekt/src/pluralize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,59 @@ const LATIN_SUFFIX_OVERRIDES: Array<[string, string]> = [
['data', 'datum'],
];

const TRAILING_TRIPLE_S_REGEX = /[sS]{3,}$/;
const TRAILING_TRIPLE_S_BEFORE_ES_REGEX = /[sS]{3,}(?=e[sS]$)/;

function normalizeTrailingSRun(suffix: string): string {
return suffix === suffix.toUpperCase() ? 'SS' : 'ss';
}

function normalizeTripleSBeforeEs(word: string): string {
return word.replace(TRAILING_TRIPLE_S_BEFORE_ES_REGEX, normalizeTrailingSRun);
}

function normalizeTrailingTripleS(word: string): string {
const match = word.match(TRAILING_TRIPLE_S_REGEX);
if (!match) {
return word;
}

const suffix = match[0];
const prefix = word.slice(0, -suffix.length);
const normalizedSuffix = normalizeTrailingSRun(suffix);
return `${prefix}${normalizedSuffix}`;
}

function normalizeMalformedDoubleS(word: string): string {
return normalizeTrailingTripleS(normalizeTripleSBeforeEs(word));
}

function enforceDoubleSPlural(singularWord: string, pluralWord: string): string {
if (!singularWord.toLowerCase().endsWith('ss')) {
return pluralWord;
}

// Defensive normalization for malformed outputs like "hazardClasss".
if (pluralWord === `${singularWord}s`) {
return `${singularWord}es`;
}

return pluralWord;
}

/**
* Convert a word to its singular form with PostGraphile-compatible Latin handling
* @example "Users" -> "User", "People" -> "Person", "Schemata" -> "Schema", "ApiSchemata" -> "ApiSchema"
*/
export function singularize(word: string): string {
const lowerWord = word.toLowerCase();
const normalizedWord = normalizeMalformedDoubleS(word);
const lowerWord = normalizedWord.toLowerCase();

for (const [pluralSuffix, singularSuffix] of LATIN_SUFFIX_OVERRIDES) {
if (lowerWord.endsWith(pluralSuffix)) {
const suffixStart = word.length - pluralSuffix.length;
const prefix = word.slice(0, suffixStart);
const originalSuffix = word.slice(suffixStart);
const suffixStart = normalizedWord.length - pluralSuffix.length;
const prefix = normalizedWord.slice(0, suffixStart);
const originalSuffix = normalizedWord.slice(suffixStart);

const isAllCaps = originalSuffix === originalSuffix.toUpperCase();
const isUpperSuffix =
Expand All @@ -55,15 +96,21 @@ export function singularize(word: string): string {
}
}

return inflection.singularize(word);
return normalizeMalformedDoubleS(inflection.singularize(normalizedWord));
}

function pluralizeCanonical(word: string): string {
const normalizedWord = normalizeMalformedDoubleS(word);
const pluralWord = normalizeMalformedDoubleS(inflection.pluralize(normalizedWord));
return enforceDoubleSPlural(singularize(normalizedWord), pluralWord);
}

/**
* Convert a word to its plural form
* @example "User" -> "Users", "Person" -> "People"
*/
export function pluralize(word: string): string {
return inflection.pluralize(word);
return pluralizeCanonical(word);
}

/**
Expand Down