diff --git a/packages/inflekt/__tests__/inflection.test.ts b/packages/inflekt/__tests__/inflection.test.ts index 6d86a36..dcd44f4 100644 --- a/packages/inflekt/__tests__/inflection.test.ts +++ b/packages/inflekt/__tests__/inflection.test.ts @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -95,6 +145,11 @@ 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', () => { @@ -102,6 +157,11 @@ describe('distinctPluralizeLast', () => { 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', () => { @@ -189,6 +249,12 @@ 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', () => { @@ -196,5 +262,7 @@ describe('toQueryName', () => { 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'); }); }); diff --git a/packages/inflekt/src/pluralize.ts b/packages/inflekt/src/pluralize.ts index 01faa91..4701469 100644 --- a/packages/inflekt/src/pluralize.ts +++ b/packages/inflekt/src/pluralize.ts @@ -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 = @@ -55,7 +96,13 @@ 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); } /** @@ -63,7 +110,7 @@ export function singularize(word: string): string { * @example "User" -> "Users", "Person" -> "People" */ export function pluralize(word: string): string { - return inflection.pluralize(word); + return pluralizeCanonical(word); } /**