feat: allow to count related docs for join fields (#11395)

### What?
For the join field query adds ability to specify `count: true`, example:
```ts
const result = await payload.find({
  joins: {
    'group.relatedPosts': {
      sort: '-title',
      count: true,
    },
  },
  collection: "categories",
})

result.group?.relatedPosts?.totalDocs // available
```

### Why?
Can be useful to implement full pagination / show total related
documents count in the UI.

### How?
Implements the logic in database adapters. In MongoDB it's additional
`$lookup` that has `$count` in the pipeline. In SQL, it's additional
subquery with `COUNT(*)`. Preserves the current behavior by default,
since counting introduces overhead.


Additionally, fixes a typescript generation error for join fields.
Before, `docs` and `hasNextPage` were marked as nullable, which is not
true, these fields cannot be `null`.
Additionally, fixes threading of `joinQuery` in
`transform/read/traverseFields` for group / tab fields recursive calls.
This commit is contained in:
Sasha
2025-02-27 18:05:48 +02:00
committed by GitHub
parent bcc68572bf
commit 3436fb16ea
9 changed files with 345 additions and 139 deletions

View File

@@ -186,6 +186,36 @@ describe('Joins Field', () => {
expect(categoryWithPosts.group.relatedPosts.docs[0].title).toStrictEqual('test 9')
})
it('should count joins', async () => {
let categoryWithPosts = await payload.findByID({
id: category.id,
joins: {
'group.relatedPosts': {
sort: '-title',
count: true,
},
},
collection: categoriesSlug,
})
expect(categoryWithPosts.group.relatedPosts?.totalDocs).toBe(15)
// With limit 1
categoryWithPosts = await payload.findByID({
id: category.id,
joins: {
'group.relatedPosts': {
sort: '-title',
count: true,
limit: 1,
},
},
collection: categoriesSlug,
})
expect(categoryWithPosts.group.relatedPosts?.totalDocs).toBe(15)
})
it('should populate relationships in joins', async () => {
const { docs } = await payload.find({
limit: 1,
@@ -1302,6 +1332,39 @@ describe('Joins Field', () => {
expect(parent.children?.docs).toHaveLength(1)
expect(parent.children.docs[0]?.value.title).toBe('doc-2')
// counting
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 1,
joins: {
children: {
count: true,
},
},
})
expect(parent.children?.totalDocs).toBe(2)
// counting filtered
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 1,
joins: {
children: {
count: true,
where: {
relationTo: {
equals: 'multiple-collections-2',
},
},
},
},
})
expect(parent.children?.totalDocs).toBe(1)
})
})
})

View File

@@ -216,9 +216,10 @@ export interface UserAuthOperations {
export interface User {
id: string;
posts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
email: string;
@@ -324,9 +325,10 @@ export interface Post {
export interface Upload {
id: string;
relatedPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -347,74 +349,90 @@ export interface Category {
id: string;
name?: string | null;
relatedPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
/**
* Static Description
*/
hasManyPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
hasManyPostsLocalized?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
hiddenPosts?: {
docs?: (string | HiddenPost)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | HiddenPost)[];
hasNextPage?: boolean;
totalDocs?: number;
};
group?: {
relatedPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
camelCasePosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
};
arrayPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
localizedArrayPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
blocksPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
polymorphic?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
polymorphics?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
localizedPolymorphic?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
localizedPolymorphics?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
singulars?: {
docs?: (string | Singular)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Singular)[];
hasNextPage?: boolean;
totalDocs?: number;
};
filtered?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
joinWithError?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
enableErrorOnJoin?: boolean | null;
updatedAt: string;
createdAt: string;
@@ -460,13 +478,15 @@ export interface Version {
export interface CategoriesVersion {
id: string;
relatedVersions?: {
docs?: (string | Version)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Version)[];
hasNextPage?: boolean;
totalDocs?: number;
};
relatedVersionsMany?: {
docs?: (string | Version)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Version)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -479,9 +499,10 @@ export interface SelfJoin {
id: string;
rel?: (string | null) | SelfJoin;
joins?: {
docs?: (string | SelfJoin)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | SelfJoin)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
@@ -504,9 +525,10 @@ export interface LocalizedCategory {
id: string;
name?: string | null;
relatedPosts?: {
docs?: (string | LocalizedPost)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | LocalizedPost)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
@@ -518,9 +540,10 @@ export interface RestrictedCategory {
id: string;
name?: string | null;
restrictedPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | Post)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
@@ -532,9 +555,10 @@ export interface CategoriesJoinRestricted {
id: string;
name?: string | null;
collectionRestrictedJoin?: {
docs?: (string | CollectionRestricted)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | CollectionRestricted)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
@@ -570,9 +594,10 @@ export interface DepthJoins1 {
id: string;
rel?: (string | null) | DepthJoins2;
joins?: {
docs?: (string | DepthJoins3)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | DepthJoins3)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
@@ -583,9 +608,10 @@ export interface DepthJoins1 {
export interface DepthJoins2 {
id: string;
joins?: {
docs?: (string | DepthJoins1)[] | null;
hasNextPage?: boolean | null;
} | null;
docs?: (string | DepthJoins1)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
@@ -606,20 +632,19 @@ export interface DepthJoins3 {
export interface MultipleCollectionsParent {
id: string;
children?: {
docs?:
| (
| {
relationTo?: 'multiple-collections-1';
value: string | MultipleCollections1;
}
| {
relationTo?: 'multiple-collections-2';
value: string | MultipleCollections2;
}
)[]
| null;
hasNextPage?: boolean | null;
} | null;
docs?: (
| {
relationTo?: 'multiple-collections-1';
value: string | MultipleCollections1;
}
| {
relationTo?: 'multiple-collections-2';
value: string | MultipleCollections2;
}
)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
@@ -656,24 +681,23 @@ export interface Folder {
folder?: (string | null) | Folder;
title?: string | null;
children?: {
docs?:
| (
| {
relationTo?: 'folders';
value: string | Folder;
}
| {
relationTo?: 'example-pages';
value: string | ExamplePage;
}
| {
relationTo?: 'example-posts';
value: string | ExamplePost;
}
)[]
| null;
hasNextPage?: boolean | null;
} | null;
docs?: (
| {
relationTo?: 'folders';
value: string | Folder;
}
| {
relationTo?: 'example-pages';
value: string | ExamplePage;
}
| {
relationTo?: 'example-posts';
value: string | ExamplePost;
}
)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}