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
75 changes: 75 additions & 0 deletions graphile/graphile-settings/__tests__/preset-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,81 @@ describe('Schema introspection', () => {
});
});

// ============================================================================
// MANY-TO-MANY COLLISION RESILIENCE
// ============================================================================
describe('Many-to-many type name collision resilience', () => {
it('schema builds without crashing when two junction tables target the same pair', async () => {
// The schema already built in beforeAll. If this describe block runs,
// the collision did NOT crash graphile-build. Verify the Bucket type
// actually exists.
const result = await query<{ __type: { name: string; fields: { name: string }[] } | null }>({
query: `
query {
__type(name: "Bucket") {
name
fields { name }
}
}
`,
});

expect(result.errors).toBeUndefined();
expect(result.data?.__type).not.toBeNull();
expect(result.data?.__type?.name).toBe('Bucket');
});

it('produces distinct m2m edge types for each junction path', async () => {
// Both "files" and "file_events" are junction tables between buckets
// and files. The inflector must produce DIFFERENT edge type names.
// Introspect all types and verify no duplicate m2m edge types exist.
const result = await query<{
__schema: { types: { name: string; fields: { name: string }[] | null }[] };
}>({
query: `
query {
__schema {
types {
name
fields { name }
}
}
}
`,
});

expect(result.errors).toBeUndefined();
const typeNames = result.data?.__schema.types.map((t) => t.name) ?? [];
const m2mEdgeTypes = typeNames.filter((n) => n.includes('ManyToManyEdge'));

// Each edge type name must be unique (no duplicates)
const unique = new Set(m2mEdgeTypes);
expect(unique.size).toBe(m2mEdgeTypes.length);
});

it('opted-in junction tables produce m2m types in the schema', async () => {
// The @behavior +manyToMany smart tag should enable m2m type registration
// for the buckets → files paths. Verify that at least one ManyToMany type
// exists in the schema (types are registered in the init hook regardless
// of field-level behavior gating).
const result = await query<{
__schema: { types: { name: string }[] };
}>({
query: `{ __schema { types { name } } }`,
});

expect(result.errors).toBeUndefined();
const typeNames = result.data?.__schema.types.map((t) => t.name) ?? [];
const allM2mTypes = typeNames.filter((n) => n.includes('ManyToMany'));
// With opt-in behavior and @behavior +manyToMany on files/file_events,
// the upstream init hook should have registered m2m types.
// If no m2m types exist at all, dump available types for debugging.
expect(allM2mTypes).toEqual(
expect.arrayContaining([expect.stringContaining('ManyToMany')]),
);
});
});

// ============================================================================
// SCALAR + LOGICAL FILTERS
// ============================================================================
Expand Down
49 changes: 49 additions & 0 deletions graphile/graphile-settings/sql/integration-seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,55 @@ INSERT INTO integration_test.tags (location_id, label) VALUES
(6, 'art'),
(7, 'outdoor');

-- ============================================================================
-- MANY-TO-MANY COLLISION TEST TABLES
-- Reproduces the scenario where two junction tables produce the same m2m type
-- name for the same left→right pair (buckets → files):
-- 1. files has FK to buckets AND a self-referential FK (parent_id),
-- so files is treated as a junction between buckets and files.
-- 2. file_events has FK to both buckets and files,
-- so it's also treated as a junction between buckets and files.
-- Without disambiguation, both paths generate the same edge/connection type
-- name and crash graphile-build.
-- ============================================================================
CREATE TABLE integration_test.buckets (
id serial PRIMARY KEY,
name text NOT NULL
);

CREATE TABLE integration_test.files (
id serial PRIMARY KEY,
bucket_id int NOT NULL REFERENCES integration_test.buckets(id),
parent_id int REFERENCES integration_test.files(id),
name text NOT NULL
);

CREATE TABLE integration_test.file_events (
id serial PRIMARY KEY,
bucket_id int NOT NULL REFERENCES integration_test.buckets(id),
file_id int NOT NULL REFERENCES integration_test.files(id),
event_type text NOT NULL
);

-- Opt-in to many-to-many on both junction tables
COMMENT ON TABLE integration_test.files IS E'@behavior +manyToMany';
COMMENT ON TABLE integration_test.file_events IS E'@behavior +manyToMany';

-- Seed data
INSERT INTO integration_test.buckets (id, name) VALUES (1, 'uploads'), (2, 'backups');
INSERT INTO integration_test.files (id, bucket_id, parent_id, name) VALUES
(1, 1, NULL, 'readme.md'),
(2, 1, 1, 'chapter1.md'),
(3, 2, NULL, 'backup.tar');
INSERT INTO integration_test.file_events (id, bucket_id, file_id, event_type) VALUES
(1, 1, 1, 'upload'),
(2, 1, 2, 'upload'),
(3, 2, 3, 'upload');

SELECT setval('integration_test.buckets_id_seq', 2);
SELECT setval('integration_test.files_id_seq', 3);
SELECT setval('integration_test.file_events_id_seq', 3);

-- Reset sequences
SELECT setval('integration_test.categories_id_seq', 3);
SELECT setval('integration_test.locations_id_seq', 7);
Expand Down
5 changes: 1 addition & 4 deletions graphile/graphile-settings/src/plugins/custom-inflector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,10 +380,7 @@ export const InflektPlugin: GraphileConfig.Plugin = {
hasDirectRelation = true;
}
}
if (
rel.isReferencee &&
rel.remoteResource?.codec?.name !== rightTable.codec.name
) {
if (rel.isReferencee) {
const junctionRelations = rel.remoteResource?.getRelations?.() || {};
for (const [_jRelName, jRel] of Object.entries(junctionRelations)) {
if (
Expand Down
35 changes: 35 additions & 0 deletions graphile/graphile-settings/src/plugins/many-to-many-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ import { PgManyToManyPreset } from '@graphile-contrib/pg-many-to-many';
*
* This overrides the default behavior from @graphile-contrib/pg-many-to-many
* to require explicit `@behavior +manyToMany` smart tags.
*
* NOTE ON TYPE REGISTRATION:
* The upstream plugin's init hook registers GraphQL types (edge/connection)
* for ALL discovered relationships unconditionally — before behavior filtering.
* Our pgManyToMany behavior only gates field creation, not type registration.
* If two junction tables produce the same inflected type name, the duplicate
* registration will crash graphile-build. The build hook below wraps
* registerObjectType to catch such collisions and skip the duplicate,
* preventing the crash without modifying the upstream plugin.
*/
export const ManyToManyOptInPlugin: GraphileConfig.Plugin = {
name: 'ManyToManyOptInPlugin',
Expand All @@ -67,6 +76,32 @@ export const ManyToManyOptInPlugin: GraphileConfig.Plugin = {
},
},
},
hooks: {
build(build) {
const originalRegister = build.registerObjectType;
(build as any).registerObjectType = function (
...args: any[]
) {
try {
return originalRegister.apply(this, args as any);
} catch (e: any) {
const origin = args[3];
if (
e.message?.includes('type naming conflict') &&
typeof origin === 'string' &&
origin.includes('many-to-many')
) {
// Silently skip duplicate many-to-many type registration.
// The first registration wins; the duplicate is harmless
// because the inflector already produced a unique field name.
return;
}
throw e;
}
};
return build;
},
},
},
};

Expand Down
Loading