From b719168b183f245ddd2d8de376152e260c3f16e5 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 22 Aug 2017 10:11:47 -0600 Subject: [PATCH] Implemented sortBy --- .../coral-embed-stream/src/graphql/index.js | 2 +- graph/context.js | 22 +- graph/loaders/comments.js | 190 ++++++++++++++---- graph/resolvers/asset.js | 3 +- graph/resolvers/comment.js | 3 +- graph/typeDefs.graphql | 20 ++ package.json | 1 + plugin-api/beta/server/getReactionConfig.js | 31 +++ services/mongoose.js | 3 +- yarn.lock | 4 + 10 files changed, 227 insertions(+), 52 deletions(-) diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index b4c5ce163..9a3718b0b 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -56,7 +56,7 @@ const extension = { fragment CoralEmbedStream_CreateCommentResponse on CreateCommentResponse { comment { ...CoralEmbedStream_CreateCommentResponse_Comment - replies { + replies(query: {}) { nodes { ...CoralEmbedStream_CreateCommentResponse_Comment } diff --git a/graph/context.js b/graph/context.js index 8d7c740b6..cd3f5ddbe 100644 --- a/graph/context.js +++ b/graph/context.js @@ -1,6 +1,7 @@ const loaders = require('./loaders'); const mutators = require('./mutators'); const uuid = require('uuid'); +const merge = require('lodash/merge'); const plugins = require('../services/plugins'); const pubsub = require('../services/pubsub'); @@ -17,17 +18,24 @@ const contextPlugins = plugins.get('server', 'context').map(({plugin, context}) }); /** - * This should itterate over the passed in plugins and load them all with the + * This should iterate over the passed in plugins and load them all with the * current graph context. * @return {Object} the saturated plugins object */ -const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduce((acc, plugin) => { - Object.keys(plugin.context).forEach((service) => { - acc[service] = plugin.context[service](context); - }); +const decorateContextPlugins = (context, contextPlugins) => { - return acc; -}, {}); + // For each of the plugins, we execute with the context to get the context + // based plugin. We then merge that into an object for the plugin. Once the + // plugin is assembled, we merge that object with all the other objects + // provided from the other plugins. + return merge(...contextPlugins.map((plugin) => { + return Object.keys(plugin.context).reduce((services, serviceName) => { + services[serviceName] = plugin.context[serviceName](context); + + return services; + }, {}); + })); +}; /** * Stores the request context. diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 170f0ed4b..d637a9ef0 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -133,18 +133,156 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, au .count(); }; +/** + * getStartCursor will retrieve the start cursor based on the sortBy field. + * + * @param {Object} ctx the graph context + * @param {Object} nodes the result set of retrieved comments + * @param {Object} params the params from the client describing the query + */ +const getStartCursor = (ctx, nodes, {cursor, sortBy}) => { + switch (sortBy) { + case 'CREATED_AT': + return nodes.length ? nodes[0].created_at : null; + case 'REPLIES': + + // The cursor is the start! This is using numeric pagination. + return cursor != null ? cursor : 0; + } + + const SORT_KEY = sortBy.toLowerCase(); + if (!ctx.plugins || !ctx.plugins.CommentSort || !ctx.plugins.CommentSort[SORT_KEY] || !ctx.plugins.CommentSort[SORT_KEY].startCursor) { + throw new Error(`unable to sort by ${sortBy}, no plugin was provided to handle this type`); + } + + return ctx.plugins.CommentSort[SORT_KEY].startCursor(ctx, nodes, {cursor}); +}; + +/** + * getEndCursor will fetch the end cursor based on the desired sortBy parameter. + * + * @param {Object} ctx the graph context + * @param {Object} nodes the result set of retrieved comments + * @param {Object} params the params from the client describing the query + */ +const getEndCursor = (ctx, nodes, {cursor, sortBy}) => { + switch (sortBy) { + case 'CREATED_AT': + return nodes.length ? nodes[nodes.length - 1].created_at : null; + case 'REPLIES': + return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null; + } + + const SORT_KEY = sortBy.toLowerCase(); + if (!ctx.plugins || !ctx.plugins.CommentSort || !ctx.plugins.CommentSort[SORT_KEY] || !ctx.plugins.CommentSort[SORT_KEY].endCursor) { + throw new Error(`unable to sort by ${sortBy}, no plugin was provided to handle this type`); + } + + return ctx.plugins.CommentSort[SORT_KEY].endCursor(ctx, nodes, {cursor}); +}; + +/** + * applySort will add the actual `.sort` and `.skip/.where` clauses to the query + * to apply the desired sort. + * + * @param {Object} ctx the graph context + * @param {Object} query the current mongoose query object + * @param {Object} params the params from the client describing the query + */ +const applySort = (ctx, query, {cursor, sort, sortBy}) => { + switch (sortBy) { + case 'CREATED_AT': { + if (cursor) { + if (sort === 'DESC') { + query = query.where({ + created_at: { + $lt: cursor, + }, + }); + } else { + query = query.where({ + created_at: { + $gt: cursor, + }, + }); + } + } + + return query.sort({created_at: sort === 'DESC' ? -1 : 1}); + } + case 'REPLIES': { + if (cursor) { + query = query.skip(cursor); + } + + return query.sort({reply_count: sort === 'DESC' ? -1 : 1, created_at: sort === 'DESC' ? -1 : 1}); + } + } + + const SORT_KEY = sortBy.toLowerCase(); + if (!ctx.plugins || !ctx.plugins.CommentSort || !ctx.plugins.CommentSort[SORT_KEY] || !ctx.plugins.CommentSort[SORT_KEY].sort) { + throw new Error(`unable to sort by ${sortBy}, no plugin was provided to handle this type`); + } + + return ctx.plugins.CommentSort[SORT_KEY].sort(ctx, query, {cursor, sort}); +}; + +/** + * executeWithSort will actually retrieve the comments based on the pre-assembled + * query and will compose on top the sort operators necessary to get the desired + * result. + * + * @param {Object} ctx the graph context + * @param {Object} query the current mongoose query object + * @param {Object} params the params from the client describing the query + */ +const executeWithSort = async (ctx, query, {cursor, sort, sortBy, limit}) => { + + // Apply the sort to the query. + query = applySort(ctx, query, {cursor, sort, sortBy}); + + // Apply the limit (if it exists, as it's applied universally). + if (limit) { + query = query.limit(limit + 1); + } + + // Fetch the nodes based on the source query. + const nodes = await query.exec(); + + // The hasNextPage is always handled the same (ask for one more than we need, + // if there is one more, than there is more). + let hasNextPage = false; + if (limit && nodes.length > limit) { + + // There was one more than we expected! Set hasNextPage = true and remove + // the last item from the array that we requested. + hasNextPage = true; + nodes.splice(limit, 1); + } + + // Use the generator functions below to extract the cursor details based on + // the current sortBy parameter. + return { + startCursor: getStartCursor(ctx, nodes, {cursor, sort, sortBy, limit}), + endCursor: getEndCursor(ctx, nodes, {cursor, sort, sortBy, limit}), + hasNextPage, + nodes, + }; +}; + /** * Retrieves comments based on the passed in query that is filtered by the * current used passed in via the context. + * * @param {Object} context graph context * @param {Object} query query terms to apply to the comments query */ -const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored, tags, action_type}) => { +const getCommentsByQuery = async (ctx, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, sortBy, excludeIgnored, tags, action_type}) => { let comments = CommentModel.find(); // Only administrators can search for comments with statuses that are not // `null`, or `'ACCEPTED'`. - if (user != null && user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses && statuses.length > 0) { + if (ctx.user != null && ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses && statuses.length > 0) { comments = comments.where({ status: { $in: statuses @@ -158,7 +296,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a }); } - if (user != null && user.can(SEARCH_OTHERS_COMMENTS) && action_type) { + if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) { comments = comments.where({ action_counts: { [action_type.toLowerCase()]: { @@ -185,7 +323,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a } // Only let an admin request any user or the current user request themself. - if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === author_id) && author_id != null) { + if (ctx.user && (ctx.user.can(SEARCH_OTHERS_COMMENTS) || ctx.user.id === author_id) && author_id != null) { comments = comments.where({author_id}); } @@ -199,50 +337,19 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a comments = comments.where({parent_id}); } - if (excludeIgnored && user && user.ignoresUsers && user.ignoresUsers.length > 0) { + if (excludeIgnored && ctx.user && ctx.user.ignoresUsers && ctx.user.ignoresUsers.length > 0) { comments = comments.where({ - author_id: {$nin: user.ignoresUsers} + author_id: {$nin: ctx.user.ignoresUsers} }); } - if (cursor) { - if (sort === 'DESC') { - comments = comments.where({ - created_at: { - $lt: cursor - } - }); - } else { - comments = comments.where({ - created_at: { - $gt: cursor - } - }); - } - } - - let query = comments - .sort({created_at: sort === 'DESC' ? -1 : 1}); - if (limit) { - query = query.limit(limit + 1); - } - return query.then((nodes) => { - let hasNextPage = false; - if (limit && nodes.length > limit) { - hasNextPage = true; - nodes.splice(limit, 1); - } - return Promise.resolve({ - startCursor: nodes.length ? nodes[0].created_at : null, - endCursor: nodes.length ? nodes[nodes.length - 1].created_at : null, - hasNextPage, - nodes, - }); - }); + return executeWithSort(ctx, comments, {cursor, sort, sortBy, limit}); }; /** - * getComments returns the comments by the id's. Only admins can see non-public comments. + * getComments returns the comments by the id's. Only admins can see non-public + * comments. + * * @param {Object} context graph context * @param {Array} ids the comment id's to fetch * @return {Promise} resolves to the comments @@ -270,6 +377,7 @@ const getComments = ({user}, ids) => { /** * Creates a set of loaders based on a GraphQL context. + * * @param {Object} context the context of the GraphQL request * @return {Object} object of loaders */ diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js index 48403ddd0..51234fd5a 100644 --- a/graph/resolvers/asset.js +++ b/graph/resolvers/asset.js @@ -17,10 +17,11 @@ const Asset = { return comments.nodes[0]; }, - comments({id}, {query: {sort, limit, excludeIgnored, tags}, deep}, {loaders: {Comments}}) { + comments({id}, {query: {sort, sortBy, limit, excludeIgnored, tags}, deep}, {loaders: {Comments}}) { return Comments.getByQuery({ asset_id: id, sort, + sortBy, limit, parent_id: deep ? undefined : null, tags, diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index f9924f90c..80ee63532 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -11,7 +11,7 @@ const Comment = { user({author_id}, _, {loaders: {Users}}) { return Users.getByID.load(author_id); }, - replies({id, asset_id, reply_count}, {query: {sort, limit, excludeIgnored}}, {loaders: {Comments}}) { + replies({id, asset_id, reply_count}, {query: {sort, sortBy, limit, excludeIgnored}}, {loaders: {Comments}}) { // Don't bother looking up replies if there aren't any there! if (reply_count === 0) { @@ -25,6 +25,7 @@ const Comment = { asset_id, parent_id: id, sort, + sortBy, limit, excludeIgnored, }); diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 4555131f4..024cbe8fb 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -249,6 +249,10 @@ input CommentsQuery { # Sort the results by from largest first. sort: SORT_ORDER = DESC + # The order to sort the comments by, sorting by default the created at + # timestamp. + sortBy: SORT_COMMENTS_BY = CREATED_AT + # Filter by a specific tag name. tags: [String!] @@ -261,6 +265,10 @@ input RepliesQuery { # Sort the results by from smallest first. sort: SORT_ORDER = ASC + # The order to sort the comments by, sorting by default the created at + # timestamp. + sortBy: SORT_COMMENTS_BY = CREATED_AT + # Limit the number of results to be returned. limit: Int = 3 @@ -660,6 +668,18 @@ enum SORT_ORDER { ASC } +# SORT_COMMENTS_BY selects the means for which comments are ordered when +# sorting. +enum SORT_COMMENTS_BY { + + # Comments will be sorted by their created at date. + CREATED_AT + + # Comments will be sorted by their immediate reply count (replies to the comment + # in question only, not including descendants). + REPLIES +} + # All queries that can be executed. enum USER_STATUS { ACTIVE diff --git a/package.json b/package.json index 63fb24de3..e89c40829 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "passport": "^0.4.0", "passport-jwt": "^3.0.0", "passport-local": "^1.0.0", + "pluralize": "^7.0.0", "postcss-loader": "^1.3.3", "postcss-modules": "^0.5.2", "postcss-smart-import": "^0.5.1", diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index 243b8915b..f0aa23324 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -1,12 +1,15 @@ const wrapResponse = require('../../../graph/helpers/response'); const {SEARCH_OTHER_USERS} = require('../../../perms/constants'); const errors = require('../../../errors'); +const pluralize = require('pluralize'); function getReactionConfig(reaction) { reaction = reaction.toLowerCase(); + const reactionPlural = pluralize(reaction); const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1); const REACTION = reaction.toUpperCase(); + const REACTION_PLURAL = reactionPlural.toUpperCase(); const typeDefs = ` enum ACTION_TYPE { @@ -26,6 +29,13 @@ function getReactionConfig(reaction) { item_id: ID! } + enum SORT_COMMENTS_BY { + + # Comments will be sorted by their count of ${reactionPlural} + # on the comment. + ${REACTION_PLURAL} + } + input Delete${Reaction}ActionInput { # The item's id for which we are deleting a ${reaction}. @@ -107,6 +117,27 @@ function getReactionConfig(reaction) { return { typeDefs, + context: { + CommentSort: () => ({ + [reactionPlural]: { + startCursor(ctx, nodes, {cursor}) { + + // The cursor is the start! This is using numeric pagination. + return cursor != null ? cursor : 0; + }, + endCursor(ctx, nodes, {cursor}) { + return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null; + }, + sort(ctx, query, {cursor, sort}) { + if (cursor) { + query = query.skip(cursor); + } + + return query.sort({[`action_counts.${reaction}`]: sort === 'DESC' ? -1 : 1, created_at: sort === 'DESC' ? -1 : 1}); + }, + }, + }), + }, resolvers: { Subscription: { [`${reaction}ActionCreated`]: ({action}) => { diff --git a/services/mongoose.js b/services/mongoose.js index 43966eb0d..fb23a5fe1 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -61,7 +61,8 @@ if (WEBPACK) { debug('connection established'); }) .catch((err) => { - throw err; + console.error(err); + process.exit(1); }); } diff --git a/yarn.lock b/yarn.lock index 2141ef7ce..7c33f202b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5648,6 +5648,10 @@ pluralize@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" +pluralize@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" + pop-iterate@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/pop-iterate/-/pop-iterate-1.0.1.tgz#ceacfdab4abf353d7a0f2aaa2c1fc7b3f9413ba3"