diff --git a/common/changes/@stonecrop/graphql-middleware/fix-middleware-amber-convention_2026-04-21-13-21.json b/common/changes/@stonecrop/graphql-middleware/fix-middleware-amber-convention_2026-04-21-13-21.json new file mode 100644 index 00000000..b2bb0d86 --- /dev/null +++ b/common/changes/@stonecrop/graphql-middleware/fix-middleware-amber-convention_2026-04-21-13-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@stonecrop/graphql-middleware", + "comment": "use correct amber preset inflection for doctypes", + "type": "patch" + } + ], + "packageName": "@stonecrop/graphql-middleware" +} \ No newline at end of file diff --git a/graphql_middleware/src/plugin/postgraphile.ts b/graphql_middleware/src/plugin/postgraphile.ts index dfb5da2d..739d5ca7 100644 --- a/graphql_middleware/src/plugin/postgraphile.ts +++ b/graphql_middleware/src/plugin/postgraphile.ts @@ -468,12 +468,13 @@ function defaultReverseConnectionName(params: { target: string }): string { const targetPlural = pluralize.plural(params.target.replace(/-/g, '_')) - const targetPascal = toPascalCase(targetPlural) + // Use camelCase for target (matches PostGraphile Amber convention: recipeTasksByRecipeId) + const targetCamel = snakeToCamel(targetPlural) // Use backlink if provided, otherwise derive from parent table name const fkSource = params.backlink || params.doctype // FK column name: uppercase first char, preserve rest of camelCase - const fkPascal = fkSource.charAt(0).toUpperCase() + fkSource.slice(1) - return `${targetPascal}By${fkPascal}Id` + const fkPascal = fkSource.charAt(0).toUpperCase() + snakeToCamel(fkSource).slice(1) + return `${targetCamel}By${fkPascal}Id` } /** diff --git a/graphql_middleware/src/registry/doctypes.ts b/graphql_middleware/src/registry/doctypes.ts index 07a9ebe1..0621eb8b 100644 --- a/graphql_middleware/src/registry/doctypes.ts +++ b/graphql_middleware/src/registry/doctypes.ts @@ -121,7 +121,13 @@ export function loadDoctypesFromObject(doctypes: Record, option * @public */ export function getMeta(name: string): DoctypeMeta | undefined { - return doctypeRegistry.get(name) + const direct = doctypeRegistry.get(name) + if (direct) return direct + // Fallback: find by slug (links reference doctypes by slug, not name) + for (const doctype of doctypeRegistry.values()) { + if (doctype.slug === name) return doctype + } + return undefined } /** diff --git a/graphql_middleware/tests/queries.test.ts b/graphql_middleware/tests/queries.test.ts index 96c53a21..953ecb94 100644 --- a/graphql_middleware/tests/queries.test.ts +++ b/graphql_middleware/tests/queries.test.ts @@ -319,7 +319,7 @@ describe('defaultReverseConnectionName', () => { backlink: 'recipe', target: 'recipe-task', }) - expect(result).toBe('RecipeTasksByRecipeId') + expect(result).toBe('recipeTasksByRecipeId') }) it('handles self-referential links', () => { @@ -329,7 +329,7 @@ describe('defaultReverseConnectionName', () => { backlink: 'supersededBy', target: 'recipe', }) - expect(result).toBe('RecipesBySupersededById') + expect(result).toBe('recipesBySupersededById') }) it('handles underscored table names', () => { @@ -339,7 +339,7 @@ describe('defaultReverseConnectionName', () => { backlink: 'bom', target: 'bom-item', }) - expect(result).toBe('BomItemsByBomId') + expect(result).toBe('bomItemsByBomId') }) }) @@ -369,7 +369,7 @@ describe('reverseConnectionName override', () => { { includeNested: true } ) // Default uses PostGraphile Amber convention - expect(query).toContain('RecipeTasksByRecipeId') + expect(query).toContain('recipeTasksByRecipeId') }) }) @@ -387,7 +387,7 @@ describe('buildRecordQuery with includeNested', () => { getMeta, { includeNested: true } ) - expect(query).toContain('RecipeTasksByRecipeId') + expect(query).toContain('recipeTasksByRecipeId') expect(query).toContain('nodes') expect(query).toContain('description') }) @@ -415,7 +415,7 @@ describe('buildRecordQuery with includeNested', () => { { includeNested: true } ) // recipe → recipe-task → recipe (seen, skipped) - expect(query).toContain('RecipeTasksByRecipeId') + expect(query).toContain('recipeTasksByRecipeId') // Should not include nested recipe sub-selection (circular) expect((query.match(/recipeById/g) || []).length).toBe(1) // only the outermost }) @@ -429,7 +429,7 @@ describe('buildRecordQuery with includeNested', () => { getMeta, { includeNested: true, maxDepth: 1 } ) - expect(query).toContain('RecipeTasksByRecipeId') + expect(query).toContain('recipeTasksByRecipeId') // RecipeTask's links should not be included (depth 1 limit) expect(query).not.toContain('recipe {') }) @@ -444,7 +444,7 @@ describe('buildRecordQuery with includeNested', () => { { includeNested: ['supersededBy'] } ) expect(query).toContain('supersededBy {') - expect(query).not.toContain('RecipeTasksByRecipeId') + expect(query).not.toContain('recipeTasksByRecipeId') }) it('skips lazy links', () => { @@ -466,7 +466,7 @@ describe('buildRecordQuery with includeNested', () => { (slug: string) => lazyRegistry.get(slug), { includeNested: true } ) - expect(query).not.toContain('RecipeTasksByRecipeId') + expect(query).not.toContain('recipeTasksByRecipeId') expect(query).toContain('name') expect(query).toContain('status') }) @@ -502,7 +502,7 @@ describe('buildRecordQuery with includeNested', () => { (slug: string) => defaultRegistry.get(slug), { includeNested: true } ) - expect(query).toContain('RecipeTasksByRecipeId(first: 50)') + expect(query).toContain('recipeTasksByRecipeId(first: 50)') }) it('blockWorkflows true forces lazy link into query', () => { @@ -524,7 +524,7 @@ describe('buildRecordQuery with includeNested', () => { (slug: string) => lazyWithBlockRegistry.get(slug), { includeNested: true } ) - expect(query).toContain('RecipeTasksByRecipeId') + expect(query).toContain('recipeTasksByRecipeId') }) }) @@ -537,7 +537,7 @@ describe('mergeNestedResults', () => { const record = { id: 'r1', name: 'Test Recipe', - RecipeTasksByRecipeId: { + recipeTasksByRecipeId: { nodes: [ { id: 't1', name: 'Task 1' }, { id: 't2', name: 'Task 2' }, @@ -551,7 +551,7 @@ describe('mergeNestedResults', () => { { id: 't1', name: 'Task 1' }, { id: 't2', name: 'Task 2' }, ]) - expect(result.RecipeTasksByRecipeId).toBeUndefined() + expect(result.recipeTasksByRecipeId).toBeUndefined() }) it('leaves atMostOne links in place', () => { @@ -570,7 +570,7 @@ describe('mergeNestedResults', () => { const record = { id: 'r1', name: 'Test Recipe', - RecipeTasksByRecipeId: { nodes: [] }, + recipeTasksByRecipeId: { nodes: [] }, } const result = mergeNestedResults({ record, meta: recipeMeta, getMeta }) @@ -623,7 +623,7 @@ describe('mergeNestedResults', () => { const record = { id: 'r1', name: 'Test Recipe', - RecipeTasksByRecipeId: { + recipeTasksByRecipeId: { nodes: [{ id: 't1', name: 'Task 1' }], }, } diff --git a/graphql_middleware/tests/registry.test.ts b/graphql_middleware/tests/registry.test.ts index 60a5e636..381f3a9d 100644 --- a/graphql_middleware/tests/registry.test.ts +++ b/graphql_middleware/tests/registry.test.ts @@ -416,6 +416,16 @@ describe('getMeta / getAllMeta / hasMeta / clearRegistry', () => { expect(meta?.name).toBe('Task') }) + it('getMeta finds doctype by slug when name lookup fails', () => { + loadDoctypesFromObject({ + RecipeTask: { name: 'RecipeTask', slug: 'recipe-task', fields: [] }, + }) + expect(getMeta('RecipeTask')).toBeDefined() + expect(getMeta('recipe-task')).toBeDefined() + expect(getMeta('recipe-task')?.name).toBe('RecipeTask') + expect(getMeta('unknown-slug')).toBeUndefined() + }) + it('hasMeta returns false before loading', () => { expect(hasMeta('Task')).toBe(false) })