chore: uses mongo aggregate for merging draft results

This commit is contained in:
James
2022-12-19 16:01:30 -05:00
parent d9d05f3644
commit c755143cc0
5 changed files with 196 additions and 233 deletions

View File

@@ -9,6 +9,7 @@ import flattenWhereConstraints from '../../utilities/flattenWhereConstraints';
import { buildSortParam } from '../../mongoose/buildSortParam';
import { AccessResult } from '../../config/types';
import { afterRead } from '../../fields/hooks/afterRead';
import { buildDraftMergeAggregate } from '../../versions/drafts/buildDraftMergeAggregate';
export type Arguments = {
collection: Collection
@@ -158,65 +159,10 @@ async function find<T extends TypeWithID = any>(incomingArgs: Arguments): Promis
};
if (collectionConfig.versions?.drafts && draftsEnabled) {
const aggregate = Model.aggregate([
{
$addFields: { id: { $toString: '$_id' } },
},
{
$lookup: {
from: `_${collectionConfig.slug}_versions`,
as: 'docs',
let: {
id: { $toString: '$_id' },
updatedAt: '$updatedAt',
},
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$parent', '$$id'] },
{ $gt: ['$updatedAt', '$$updatedAt'] },
],
},
},
},
{ $sort: { updatedAt: -1 } },
{ $limit: 1 },
],
},
},
// WHEN docs.length > 0
{
$project: {
doc: { $arrayElemAt: ['$docs', 0] },
},
},
{
$addFields: {
createdAt: '$doc.createdAt',
updatedAt: '$doc.updatedAt',
},
},
{
$replaceRoot: {
newRoot: {
$mergeObjects: ['$doc.version', '$$ROOT'],
},
},
},
// End "when"
{
$project: {
docs: 0,
doc: 0,
},
},
{
$match: query,
},
]);
const aggregate = Model.aggregate(buildDraftMergeAggregate({
config: collectionConfig,
query,
}));
result = await Model.aggregatePaginate(aggregate, paginationOptions);
} else {

View File

@@ -10,7 +10,7 @@ const buildModel = (config: SanitizedConfig): GlobalModel | null => {
globalsSchema.plugin(buildQueryPlugin);
const Globals = mongoose.model('globals', globalsSchema) as GlobalModel;
const Globals = mongoose.model('globals', globalsSchema) as unknown as GlobalModel;
Object.values(config.globals).forEach((globalConfig) => {
const globalSchema = buildSchema(config, globalConfig.fields, { global: true });

View File

@@ -0,0 +1,95 @@
import { PipelineStage } from 'mongoose';
import { SanitizedCollectionConfig } from '../../collections/config/types';
type Args = {
config: SanitizedCollectionConfig
query: Record<string, unknown>
}
export const buildDraftMergeAggregate = ({ config, query }: Args): PipelineStage[] => [
// Add string-based ID to query with
{
$addFields: { id: { $toString: '$_id' } },
},
// Merge in one version
// that has the same parent ID
// and is newer, sorting by updatedAt
{
$lookup: {
from: `_${config.slug}_versions`,
as: 'docs',
let: {
id: { $toString: '$_id' },
updatedAt: '$updatedAt',
},
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$parent', '$$id'] },
{ $gt: ['$updatedAt', '$$updatedAt'] },
],
},
},
},
{ $sort: { updatedAt: -1 } },
{ $limit: 1 },
],
},
},
// Add a new field
// with the first doc returned
{
$addFields: {
doc: {
$arrayElemAt: ['$docs', 0],
},
},
},
// If newer version exists,
// merge the version into the root and
// replace the updatedAt
// Otherwise, do nothing to the root
{
$replaceRoot: {
newRoot: {
$cond: {
if: {
$ne: ['$doc', undefined],
},
then: {
$mergeObjects: [
'$$ROOT',
{
$mergeObjects: [
'$doc.version',
{
updatedAt: '$doc.updatedAt',
},
],
},
],
},
else: '$$ROOT',
},
},
},
},
// Clear out the temporarily added `docs`
{
$project: {
doc: 0,
docs: 0,
},
},
// Run the original query on the results
{
$match: query,
},
];

View File

@@ -1,173 +0,0 @@
import { AccessResult } from '../../config/types';
import { docHasTimestamps, Where } from '../../types';
import { Payload } from '../..';
import { PaginatedDocs } from '../../mongoose/types';
import { Collection, CollectionModel, TypeWithID } from '../../collections/config/types';
import { hasWhereAccessResult } from '../../auth';
import { appendVersionToQueryKey } from './appendVersionToQueryKey';
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
import replaceWithDraftIfAvailable from './replaceWithDraftIfAvailable';
type AggregateVersion<T> = {
_id: string
version: T
updatedAt: string
createdAt: string
}
type Args<T> = {
accessResult: AccessResult
collection: Collection
locale: string
originalQueryResult: PaginatedDocs<T>
paginationOptions: any
payload: Payload
where: Where
}
export const mergeDrafts = async <T extends TypeWithID>({
accessResult,
collection,
locale,
originalQueryResult,
payload,
paginationOptions,
where: incomingWhere,
}: Args<T>): Promise<PaginatedDocs<T>> => {
const VersionModel = payload.versions[collection.config.slug] as CollectionModel;
const where = appendVersionToQueryKey(incomingWhere || {});
const versionQueryToBuild: { where: Where } = {
where: {
...where,
and: [
...where?.and || [],
{
'version._status': {
equals: 'draft',
},
},
],
},
};
if (hasWhereAccessResult(accessResult)) {
const versionAccessResult = appendVersionToQueryKey(accessResult);
versionQueryToBuild.where.and.push(versionAccessResult);
}
const versionQuery = await VersionModel.buildQuery(versionQueryToBuild, locale);
const matchedDraftVersions = await VersionModel.aggregate<AggregateVersion<T>>([
{ $sort: { updatedAt: -1 } },
{ $match: versionQuery },
{
$group: {
_id: '$parent',
version: { $first: '$version' },
updatedAt: { $first: '$updatedAt' },
createdAt: { $first: '$createdAt' },
},
},
]);
const matchedDrafts: AggregateVersion<T>[] = [];
const unmatchedDrafts: AggregateVersion<T>[] = [];
matchedDraftVersions.forEach((draft) => {
const matchedDocFromOriginalQuery = originalQueryResult.docs.find(({ id }) => id === draft._id);
const sanitizedDraft = JSON.parse(JSON.stringify(draft));
// If we find a matched doc from the original query,
// No need to store this doc
if (matchedDocFromOriginalQuery) {
matchedDrafts.push(sanitizedDraft);
} else {
unmatchedDrafts.push(sanitizedDraft);
}
});
let result = originalQueryResult;
// If there are results from drafts,
// we need to re-query while explicitly passing in
// the IDs of the un-matched drafts so that they appear correctly
// in paginated results, properly paginated
if (unmatchedDrafts.length > 0) {
const whereWithUnmatchedIDs: { where: Where } = {
where: {
and: [],
},
};
if (hasWhereAccessResult(accessResult)) {
whereWithUnmatchedIDs.where.and.push(accessResult);
}
if (where) {
whereWithUnmatchedIDs.where.and.push({
or: [
where,
{
id: {
in: unmatchedDrafts.map(({ _id }) => _id),
},
},
],
});
}
const queryWithUnmatchedIDs = await collection.Model.buildQuery(whereWithUnmatchedIDs, locale);
result = await collection.Model.paginate(queryWithUnmatchedIDs, paginationOptions);
result = {
...result,
docs: result.docs.map((doc) => {
const sanitizedDoc = JSON.parse(JSON.stringify(doc));
sanitizedDoc.id = sanitizedDoc._id;
return sanitizeInternalFields(sanitizedDoc);
}),
};
}
result = {
...result,
docs: await Promise.all(result.docs.map(async (doc) => {
// If we have an existing unmatched draft, we can replace with that if it's newer
const correlatedUnmatchedDraft = unmatchedDrafts.find(({ _id, updatedAt }) => _id === doc.id && docHasTimestamps(doc) && updatedAt > doc.updatedAt);
if (correlatedUnmatchedDraft) {
return {
...doc,
...correlatedUnmatchedDraft.version,
createdAt: correlatedUnmatchedDraft.createdAt,
updatedAt: correlatedUnmatchedDraft.updatedAt,
};
}
const correlatedMatchedDraft = matchedDrafts.find(({ _id, updatedAt }) => _id === doc.id && docHasTimestamps(doc) && updatedAt > doc.updatedAt);
if (correlatedMatchedDraft) {
return {
...doc,
...correlatedMatchedDraft.version,
createdAt: correlatedMatchedDraft.createdAt,
updatedAt: correlatedMatchedDraft.updatedAt,
};
}
return replaceWithDraftIfAvailable({
accessResult,
payload,
entity: collection.config,
entityType: 'collection',
doc,
locale,
});
})),
};
return result;
};

View File

@@ -258,6 +258,101 @@ describe('Versions', () => {
});
});
describe('Querying', () => {
const originalTitle = 'original title';
const updatedTitle1 = 'new title 1';
const updatedTitle2 = 'new title 2';
let firstDraft;
beforeAll(async () => {
// This will be created in the `draft-posts` collection
firstDraft = await payload.create({
collection: 'draft-posts',
data: {
title: originalTitle,
description: 'my description',
radio: 'test',
},
});
// This will be created in the `_draft-posts_versions` collection
await payload.update({
collection: 'draft-posts',
id: firstDraft.id,
draft: true,
data: {
title: updatedTitle1,
},
});
// This will be created in the `_draft-posts_versions` collection
// and will be the newest draft, able to be queried on
await payload.update({
collection: 'draft-posts',
id: firstDraft.id,
draft: true,
data: {
title: updatedTitle2,
},
});
});
it('should allow querying a draft doc from main collection', async () => {
const findResults = await payload.find({
collection: 'draft-posts',
where: {
title: {
equals: originalTitle,
},
},
});
expect(findResults.docs[0].title).toStrictEqual(originalTitle);
});
it('should not be able to query an old draft version with draft=true', async () => {
const draftFindResults = await payload.find({
collection: 'draft-posts',
draft: true,
where: {
title: {
equals: updatedTitle1,
},
},
});
expect(draftFindResults.docs).toHaveLength(0);
});
it('should be able to query the newest draft version with draft=true', async () => {
const draftFindResults = await payload.find({
collection: 'draft-posts',
draft: true,
where: {
title: {
equals: updatedTitle2,
},
},
});
expect(draftFindResults.docs[0].title).toStrictEqual(updatedTitle2);
});
it('should not be able to query old drafts that don\'t match with draft=true', async () => {
const draftFindResults = await payload.find({
collection: 'draft-posts',
draft: true,
where: {
title: {
equals: originalTitle,
},
},
});
expect(draftFindResults.docs).toHaveLength(0);
});
});
describe('Collections - GraphQL', () => {
describe('Create', () => {
it('should allow a new doc to be created with draft status', async () => {