fix(graphql): error querying hasMany relationships when some document was deleted (#14002)

When you have a `hasMany: true` relationship field with at least 1 ID
that references nothing (because the actual document was deleted and
since MongoDB doesn't have foreign constraints - the relationship field
still includes that "dead" ID) graphql querying of that field fails.
This PR fixes it.

The same applies if you don't have access to some document for all DBs
This commit is contained in:
Sasha
2025-10-01 20:28:17 +03:00
committed by GitHub
parent accd95ec8a
commit 48e9576dec
5 changed files with 148 additions and 22 deletions

View File

@@ -84,7 +84,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
defaultIDType: string;
};
globals: {
menu: Menu;
@@ -124,7 +124,7 @@ export interface UserAuthOperations {
* via the `definition` "posts".
*/
export interface Post {
id: number;
id: string;
title?: string | null;
content?: {
root: {
@@ -149,7 +149,7 @@ export interface Post {
* via the `definition` "media".
*/
export interface Media {
id: number;
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -193,7 +193,7 @@ export interface Media {
* via the `definition` "users".
*/
export interface User {
id: number;
id: string;
updatedAt: string;
createdAt: string;
email: string;
@@ -217,24 +217,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
id: string;
document?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: number | Media;
value: string | Media;
} | null)
| ({
relationTo: 'users';
value: number | User;
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
updatedAt: string;
createdAt: string;
@@ -244,10 +244,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
id: string;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
key?: string | null;
value?:
@@ -267,7 +267,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "menu".
*/
export interface Menu {
id: number;
id: string;
globalText?: string | null;
updatedAt?: string | null;
createdAt?: string | null;

View File

@@ -19,7 +19,10 @@ const openAccess = {
update: () => true,
}
const collectionWithName = (collectionSlug: string): CollectionConfig => {
const collectionWithName = (
collectionSlug: string,
extra: Partial<CollectionConfig> = {},
): CollectionConfig => {
return {
slug: collectionSlug,
access: openAccess,
@@ -29,6 +32,7 @@ const collectionWithName = (collectionSlug: string): CollectionConfig => {
type: 'text',
},
],
...extra,
}
}
@@ -244,6 +248,7 @@ export default buildConfigWithDefaults({
],
},
],
versions: { drafts: true },
},
{
slug: 'custom-ids',
@@ -261,7 +266,15 @@ export default buildConfigWithDefaults({
},
],
},
collectionWithName(relationSlug),
collectionWithName(relationSlug, {
access: {
...openAccess,
read: () => {
return { name: { not_equals: 'restricted' } }
},
},
versions: { drafts: true },
}),
collectionWithName('dummy'),
{
...collectionWithName(errorOnHookSlug),

View File

@@ -12,6 +12,8 @@ import { idToString } from '../helpers/idToString.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { errorOnHookSlug, pointSlug, relationSlug, slug } from './config.js'
const formatID = (id: number | string) => (typeof id === 'number' ? id : `"${id}"`)
const title = 'title'
let restClient: NextRESTClient
@@ -1010,6 +1012,99 @@ describe('collections-graphql', () => {
const queriedDoc = res.data.CyclicalRelationships.docs[0]
expect(queriedDoc.title).toEqual(queriedDoc.relationToSelf.title)
})
it('should still query hasMany relationships when some document was deleted', async () => {
const relation_1_draft = await payload.create({
collection: 'relation',
data: { _status: 'draft', name: 'relation_1_draft' },
draft: true,
})
const relation_2 = await payload.create({
collection: 'relation',
data: { name: 'relation_2', _status: 'published' },
})
await payload.create({
collection: 'posts',
draft: true,
data: {
_status: 'draft',
title: 'post with relations in draft',
relationHasManyField: [relation_1_draft.id, relation_2.id],
},
})
await payload.delete({ collection: 'relation', id: relation_1_draft.id })
const query = `query {
Posts(draft:true,where: { title: { equals: "post with relations in draft" }}) {
docs {
id
title
relationHasManyField {
id,
name
}
}
totalDocs
}
}`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((res) => res.json())
const queriedDoc = res.data.Posts.docs[0]
expect(queriedDoc.title).toBe('post with relations in draft')
expect(queriedDoc.relationHasManyField[0].id).toBe(relation_2.id)
})
it('should still query hasMany relationships when user doesnt have access to some document', async () => {
const relation_1_draft = await payload.create({
collection: 'relation',
data: { name: 'restricted' },
})
const relation_2 = await payload.create({
collection: 'relation',
data: { name: 'relation_2' },
})
await payload.create({
collection: 'posts',
draft: true,
data: {
_status: 'draft',
title: 'post with relation restricted',
relationHasManyField: [relation_1_draft.id, relation_2.id],
},
})
const query = `query {
Posts(draft:true,where: { title: { equals: "post with relation restricted" }}) {
docs {
id
title
relationHasManyField {
id,
name
}
}
totalDocs
}
}`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((res) => res.json())
const queriedDoc = res.data.Posts.docs[0]
expect(queriedDoc.title).toBe('post with relation restricted')
expect(queriedDoc.relationHasManyField[0].id).toBe(relation_2.id)
})
})
})
@@ -1106,7 +1201,7 @@ describe('collections-graphql', () => {
})
const query = `{
CyclicalRelationship(id: ${typeof newDoc.id === 'number' ? newDoc.id : `"${newDoc.id}"`}) {
CyclicalRelationship(id: ${formatID(newDoc.id)}) {
media {
id
title

View File

@@ -148,6 +148,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -219,6 +226,7 @@ export interface Post {
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -229,6 +237,7 @@ export interface Relation {
name?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -435,6 +444,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -494,6 +510,7 @@ export interface PostsSelect<T extends boolean = true> {
};
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -513,6 +530,7 @@ export interface RelationSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema