UNPKG

@wepublish/api-db-mongodb

Version:

We.publish Database adapter for mongoDB

518 lines (435 loc) 13.9 kB
import { DBArticleAdapter, CreateArticleArgs, Article, UpdateArticleArgs, OptionalArticle, DeleteArticleArgs, PublishArticleArgs, UnpublishArticleArgs, ConnectionResult, GetArticlesArgs, LimitType, InputCursorType, SortOrder, ArticleSort, OptionalPublicArticle, GetPublishedArticlesArgs, PublicArticle } from '@wepublish/api' import {Collection, Db, FilterQuery, MongoCountPreferences} from 'mongodb' import {CollectionName, DBArticle} from './schema' import {MaxResultsPerPage} from './defaults' import {Cursor} from './cursor' import {escapeRegExp} from '../utility' export class MongoDBArticleAdapter implements DBArticleAdapter { private articles: Collection<DBArticle> private locale: string constructor(db: Db, locale: string) { this.articles = db.collection(CollectionName.Articles) this.locale = locale } async createArticle({input}: CreateArticleArgs): Promise<Article> { const {shared, ...data} = input const {ops} = await this.articles.insertOne({ shared, createdAt: new Date(), modifiedAt: new Date(), draft: { revision: 0, createdAt: new Date(), ...data }, pending: null, published: null }) const {_id: id, ...article} = ops[0] return {id, ...article} } async updateArticle({id, input}: UpdateArticleArgs): Promise<OptionalArticle> { const {shared, ...data} = input // TODO: Escape user input with `$literal`, check other adapters aswell. const {value} = await this.articles.findOneAndUpdate( {_id: id}, [ { $set: { shared, modifiedAt: new Date(), 'draft.revision': { $ifNull: [ '$draft.revision', { $cond: [ {$ne: ['$pending', null]}, {$add: ['$pending.revision', 1]}, { $cond: [{$ne: ['$published', null]}, {$add: ['$published.revision', 1]}, 0] } ] } ] }, 'draft.createdAt': { $ifNull: ['$draft.createdAt', new Date()] }, 'draft.title': data.title, 'draft.preTitle': data.preTitle, 'draft.lead': data.lead, 'draft.seoTitle': data.seoTitle, 'draft.slug': data.slug, 'draft.imageID': data.imageID, 'draft.authorIDs': data.authorIDs, 'draft.tags': data.tags, 'draft.breaking': data.breaking, 'draft.properties': data.properties, 'draft.blocks': data.blocks, 'draft.hideAuthor': data.hideAuthor, 'draft.canonicalUrl': data.canonicalUrl, 'draft.socialMediaTitle': data.socialMediaTitle, 'draft.socialMediaAuthorIDs': data.socialMediaAuthorIDs, 'draft.socialMediaDescription': data.socialMediaDescription, 'draft.socialMediaImageID': data.socialMediaImageID } } ] as any, {returnOriginal: false} ) if (!value) return null const {_id: outID, ...article} = value return {id: outID, ...article} } async deleteArticle({id}: DeleteArticleArgs): Promise<boolean | null> { const {deletedCount} = await this.articles.deleteOne({_id: id}) return deletedCount !== 0 ? true : null } async publishArticle({ id, publishAt, publishedAt, updatedAt }: PublishArticleArgs): Promise<OptionalArticle> { publishAt = publishAt ?? new Date() if (publishAt > new Date()) { const {value} = await this.articles.findOneAndUpdate( {_id: id}, [ { $set: { modifiedAt: new Date(), pending: { $cond: [ {$ne: ['$draft', null]}, '$draft', { $cond: [ {$ne: ['$pending', null]}, '$pending', {$cond: [{$ne: ['$published', null]}, '$published', null]} ] } ] }, draft: null } }, { $set: { 'pending.publishAt': publishAt, 'pending.publishedAt': publishedAt ?? { $cond: [{$ne: ['$published', null]}, '$published.publishedAt', publishAt] }, 'pending.updatedAt': updatedAt ?? publishAt } } ] as any, {returnOriginal: false} ) if (!value) return null const {_id: outID, ...article} = value return {id: outID, ...article} } else { const {value} = await this.articles.findOneAndUpdate( {_id: id}, [ { $set: { tempPublishedAt: '$published.publishedAt' } }, { $set: { published: { $ifNull: ['$draft', {$ifNull: ['$pending', '$published']}] }, pending: null, draft: null } }, { $set: { 'published.publishedAt': publishedAt ?? { $ifNull: ['$tempPublishedAt', publishAt] }, 'published.updatedAt': updatedAt ?? publishAt } }, { $unset: ['tempPublishedAt', 'published.publishAt'] } ] as any, {returnOriginal: false} ) if (!value) return null const {_id: outID, ...article} = value return {id: outID, ...article} } } async unpublishArticle({id}: UnpublishArticleArgs): Promise<OptionalArticle> { const {value} = await this.articles.findOneAndUpdate( {_id: id}, [ { $set: { draft: { $ifNull: ['$draft', {$ifNull: ['$pending', '$published']}] }, pending: null, published: null } }, { $unset: ['draft.publishAt', 'draft.publishedAt', 'draft.updatedAt'] } ] as any, {returnOriginal: false} ) if (!value) return null const {_id: outID, ...article} = value return {id: outID, ...article} } async getArticlesByID(ids: readonly string[]): Promise<OptionalArticle[]> { await this.updatePendingArticles() const articles = await this.articles.find({_id: {$in: ids}}).toArray() const articleMap = Object.fromEntries( articles.map(({_id: id, ...article}) => [id, {id, ...article}]) ) return ids.map(id => articleMap[id] ?? null) } // TODO: Deduplicate getImages, getPages, getAuthors async getArticles({ filter, sort, order, cursor, limit }: GetArticlesArgs): Promise<ConnectionResult<Article>> { await this.updatePendingArticles() const limitCount = Math.min(limit.count, MaxResultsPerPage) const sortDirection = limit.type === LimitType.First ? order : -order const cursorData = cursor.type !== InputCursorType.None ? Cursor.from(cursor.data) : undefined const expr = order === SortOrder.Ascending ? cursor.type === InputCursorType.After ? '$gt' : '$lt' : cursor.type === InputCursorType.After ? '$lt' : '$gt' const sortField = articleSortFieldForSort(sort) const cursorFilter = cursorData ? { $or: [ {[sortField]: {[expr]: cursorData.date}}, {_id: {[expr]: cursorData.id}, [sortField]: cursorData.date} ] } : {} let stateFilter: FilterQuery<any> = {} let textFilter: FilterQuery<any> = {} let metaFilters: FilterQuery<any> = [] if (filter?.title != undefined) { // TODO: Only match based on state filter textFilter['$or'] = [ {'draft.title': {$regex: escapeRegExp(filter.title), $options: 'i'}}, {'pending.title': {$regex: escapeRegExp(filter.title), $options: 'i'}}, {'published.title': {$regex: escapeRegExp(filter.title), $options: 'i'}} ] } if (filter?.published != undefined) { stateFilter['published'] = {[filter.published ? '$ne' : '$eq']: null} } if (filter?.draft != undefined) { stateFilter['draft'] = {[filter.draft ? '$ne' : '$eq']: null} } if (filter?.pending != undefined) { stateFilter['pending'] = {[filter.pending ? '$ne' : '$eq']: null} } if (filter?.shared != undefined) { stateFilter['shared'] = {[filter.shared ? '$ne' : '$eq']: false} } if (filter?.tags) { // TODO: Only match based on state filter metaFilters.push({ $or: [ {'draft.tags': {$in: filter.tags}}, {'pending.tags': {$in: filter.tags}}, {'published.tags': {$in: filter.tags}} ] }) } if (filter?.authors) { // TODO: Only match based on state filter metaFilters.push({ $or: [ {'draft.authorIDs': {$in: filter.authors}}, {'pending.authorIDs': {$in: filter.authors}}, {'published.authorIDs': {$in: filter.authors}} ] }) } // TODO: Check index usage const [totalCount, articles] = await Promise.all([ this.articles.countDocuments( {$and: [stateFilter, metaFilters.length ? {$and: metaFilters} : {}, textFilter]} as any, {collation: {locale: this.locale, strength: 2}} as MongoCountPreferences ), // MongoCountPreferences doesn't include collation this.articles .aggregate([], {collation: {locale: this.locale, strength: 2}}) .match(stateFilter) .match(metaFilters.length ? {$and: metaFilters} : {}) .match(textFilter) .match(cursorFilter) .sort({[sortField]: sortDirection, _id: sortDirection}) .skip(limit.skip ?? 0) .limit(limitCount + 1) .toArray() ]) const nodes = articles.slice(0, limitCount) if (limit.type === LimitType.Last) { nodes.reverse() } const hasNextPage = limit.type === LimitType.First ? articles.length > limitCount : cursor.type === InputCursorType.Before ? true : false const hasPreviousPage = limit.type === LimitType.Last ? articles.length > limitCount : cursor.type === InputCursorType.After ? true : false const firstArticle = nodes[0] const lastArticle = nodes[nodes.length - 1] const startCursor = firstArticle ? new Cursor(firstArticle._id, articleDateForSort(firstArticle, sort)).toString() : null const endCursor = lastArticle ? new Cursor(lastArticle._id, articleDateForSort(lastArticle, sort)).toString() : null return { nodes: nodes.map<Article>(({_id: id, ...article}) => ({id, ...article})), pageInfo: { startCursor, endCursor, hasNextPage, hasPreviousPage }, totalCount } } async getPublishedArticlesByID(ids: readonly string[]): Promise<OptionalPublicArticle[]> { await this.updatePendingArticles() const articles = await this.articles .find({_id: {$in: ids}, $or: [{published: {$ne: null}}, {pending: {$ne: null}}]}) .toArray() const articleMap = Object.fromEntries( articles.map(({_id: id, shared, published, pending}) => [ id, {id, shared, ...(published || pending)!} ]) ) return ids.map(id => (articleMap[id] as PublicArticle) ?? null) } async getPublishedArticleBySlug(slug: string): Promise<OptionalPublicArticle> { await this.updatePendingArticles() const article = await this.articles.findOne({ $or: [ {published: {$ne: null}, 'published.slug': {$eq: slug}}, {pending: {$ne: null}, 'pending.slug': {$eq: slug}} ] }) return article?.published || article?.pending ? ({ id: article._id, shared: article.shared, ...(article.published || article.pending) } as PublicArticle) : null } async getPublishedArticles({ filter, sort, order, cursor, limit }: GetPublishedArticlesArgs): Promise<ConnectionResult<PublicArticle>> { const {nodes, pageInfo, totalCount} = await this.getArticles({ filter: {...filter, published: true}, sort, order, cursor, limit }) return { nodes: nodes.map( article => ({id: article.id, shared: article.shared, ...article.published!} as PublicArticle) ), pageInfo, totalCount } } // TODO: Throttle or cron this function async updatePendingArticles(): Promise<void> { await this.articles.updateMany({'pending.publishAt': {$lte: new Date()}}, [ { $set: { modifiedAt: new Date(), published: '$pending', pending: null } } ] as any) } } function articleSortFieldForSort(sort: ArticleSort) { switch (sort) { case ArticleSort.CreatedAt: return 'createdAt' case ArticleSort.ModifiedAt: return 'modifiedAt' case ArticleSort.PublishedAt: return 'published.publishedAt' case ArticleSort.UpdatedAt: return 'published.updatedAt' case ArticleSort.PublishAt: return 'pending.publishAt' } } function articleDateForSort(article: DBArticle, sort: ArticleSort): Date | undefined { switch (sort) { case ArticleSort.CreatedAt: return article.createdAt case ArticleSort.ModifiedAt: return article.modifiedAt case ArticleSort.PublishedAt: return article.published?.publishedAt case ArticleSort.UpdatedAt: return article.published?.updatedAt case ArticleSort.PublishAt: return article.pending?.publishAt } }