From 1b45af0650f4ce54c239b01e85f191fc6894751b Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 31 Jan 2023 13:25:28 +0000 Subject: [PATCH 1/2] add Fisher-Yates shuffle to array utils --- src/Utils/Array.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Utils/Array.ts b/src/Utils/Array.ts index f2b6ef1..b4fa3ea 100644 --- a/src/Utils/Array.ts +++ b/src/Utils/Array.ts @@ -3,4 +3,17 @@ export function arrayRemove(array: T[], item: T) { if (idx > -1) { array.splice(idx, 1) } -} \ No newline at end of file +} + +// Fisher-Yates shuffle +export function shuffle(array: T[]) { + const _array = [...array] + // traverse array from end to start + for (let i = _array.length - 1; i > 0; i--) { + // random index from 0 to i + let j = Math.floor(Math.random() * (i + 1)) + // swap elements array[i] and array[j] + ;[_array[i], _array[j]] = [_array[j], _array[i]] + } + return _array +} From a86e3d617b1a45a4c61ef2f0c237d24ea8691e8d Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 31 Jan 2023 13:25:51 +0000 Subject: [PATCH 2/2] shuffle tags before reducing into a max limit search query --- src/Resolver/ArticleResolver.ts | 427 ++++++++++++++++---------------- 1 file changed, 217 insertions(+), 210 deletions(-) diff --git a/src/Resolver/ArticleResolver.ts b/src/Resolver/ArticleResolver.ts index 59f3799..7de740e 100644 --- a/src/Resolver/ArticleResolver.ts +++ b/src/Resolver/ArticleResolver.ts @@ -1,240 +1,247 @@ -import { ApolloError } from "apollo-server-express"; -import { Arg, Args, Ctx, FieldResolver, Int, Query, Resolver, Root } from "type-graphql" -import { Action, FiltersAction } from "../Entity/Action"; -import { Article, ArticleFilters } from "../Entity/Article"; -import { ArticleGenerativeToken } from "../Entity/ArticleGenerativeToken"; -import { ArticleLedger } from "../Entity/ArticleLedger"; -import { ArticleRevision } from "../Entity/ArticleRevision"; -import { Listing } from "../Entity/Listing"; -import { MediaImage } from "../Entity/MediaImage"; -import { Split } from "../Entity/Split"; -import { User } from "../Entity/User"; -import { articleQueryFilter } from "../Query/Filters/Article"; -import { RequestContext } from "../types/RequestContext"; -import { processFilters } from "../Utils/Filters"; -import { PaginationArgs, useDefaultValues } from "./Arguments/Pagination"; -import { ActionsSortInput, ArticleSortInput, defaultSort, ListingsSortInput } from "./Arguments/Sort"; +import { ApolloError } from "apollo-server-express" +import { + Arg, + Args, + Ctx, + FieldResolver, + Int, + Query, + Resolver, + Root, +} from "type-graphql" +import { Action, FiltersAction } from "../Entity/Action" +import { Article, ArticleFilters } from "../Entity/Article" +import { ArticleGenerativeToken } from "../Entity/ArticleGenerativeToken" +import { ArticleLedger } from "../Entity/ArticleLedger" +import { ArticleRevision } from "../Entity/ArticleRevision" +import { Listing } from "../Entity/Listing" +import { MediaImage } from "../Entity/MediaImage" +import { Split } from "../Entity/Split" +import { User } from "../Entity/User" +import { articleQueryFilter } from "../Query/Filters/Article" +import { RequestContext } from "../types/RequestContext" +import { shuffle } from "../Utils/Array" +import { processFilters } from "../Utils/Filters" +import { PaginationArgs, useDefaultValues } from "./Arguments/Pagination" +import { + ActionsSortInput, + ArticleSortInput, + defaultSort, + ListingsSortInput, +} from "./Arguments/Sort" @Resolver(Article) export class ArticleResolver { @Query(returns => Article, { - nullable: true, - description: "Get an Article by its ID or SLUG. One of those 2 must be provided for the endpoint to perform a search in the DB." - }) - article( - @Arg('id', () => Int, { nullable: true }) id: number, - @Arg('slug', { nullable: true }) slug: string, - ): Promise { - if (id == null && slug == null) { - throw new ApolloError("Either ID or SLUG must be supllied.") - } - if (id != null) { - return Article.findOne(id) - } - else { - return Article.findOne({ - where: { slug } - }) - } - } + nullable: true, + description: + "Get an Article by its ID or SLUG. One of those 2 must be provided for the endpoint to perform a search in the DB.", + }) + article( + @Arg("id", () => Int, { nullable: true }) id: number, + @Arg("slug", { nullable: true }) slug: string + ): Promise
{ + if (id == null && slug == null) { + throw new ApolloError("Either ID or SLUG must be supllied.") + } + if (id != null) { + return Article.findOne(id) + } else { + return Article.findOne({ + where: { slug }, + }) + } + } @Query(returns => [Article], { - description: "Generic endpoint to query the Articlres. Requires pagination and provides sort and filter options." - }) - async articles( - @Args() { skip, take }: PaginationArgs, - @Arg("sort", { nullable: true }) sort: ArticleSortInput, - @Arg("filters", ArticleFilters, { nullable: true }) filters: any - ): Promise { - // default arguments - if (!sort || Object.keys(sort).length === 0) { - sort = { - createdAt: "DESC" - } - } - [skip, take] = useDefaultValues([skip, take], [0, 20]) - - let query = Article.createQueryBuilder("article").select() - - // apply the filters/sort - query = await articleQueryFilter( - query, - filters, - sort, - ) - - // add pagination - query.take(take) - query.skip(skip) - - return query.getMany() - } + description: + "Generic endpoint to query the Articlres. Requires pagination and provides sort and filter options.", + }) + async articles( + @Args() { skip, take }: PaginationArgs, + @Arg("sort", { nullable: true }) sort: ArticleSortInput, + @Arg("filters", ArticleFilters, { nullable: true }) filters: any + ): Promise { + // default arguments + if (!sort || Object.keys(sort).length === 0) { + sort = { + createdAt: "DESC", + } + } + ;[skip, take] = useDefaultValues([skip, take], [0, 20]) + + let query = Article.createQueryBuilder("article").select() + + // apply the filters/sort + query = await articleQueryFilter(query, filters, sort) + + // add pagination + query.take(take) + query.skip(skip) + + return query.getMany() + } @FieldResolver(() => User, { - description: "The author of the article" + description: "The author of the article", }) - author( - @Root() article: Article, - @Ctx() ctx: RequestContext, - ) { + author(@Root() article: Article, @Ctx() ctx: RequestContext) { return ctx.usersLoader.load(article.authorId) } @FieldResolver(() => [ArticleLedger], { - description: "The ledger (owners & editions) of the article." + description: "The ledger (owners & editions) of the article.", + }) + ledger(@Root() article: Article, @Ctx() ctx: RequestContext) { + return ctx.articlesLedgersLoader.load(article.id) + } + + @FieldResolver(() => [Listing], { + description: "Get an article active listings.", }) - ledger( + activeListings( @Root() article: Article, @Ctx() ctx: RequestContext, - ) { - return ctx.articlesLedgersLoader.load(article.id) - } - - @FieldResolver(() => [Listing], { - description: "Get an article active listings." - }) - activeListings( - @Root() article: Article, - @Ctx() ctx: RequestContext, - @Arg("sort", { nullable: true }) sort: ListingsSortInput - ) { - // default sort argument - if (!sort || Object.keys(sort).length === 0) { - sort = { - price: "ASC" - } - } - - return ctx.articleActiveListingsLoader.load({ - id: article.id, - sort, - }) - } - - @FieldResolver(returns => MediaImage, { - description: "The media entity associated with the thumbnail of the article, provides additional informations on the thumbnail such as resolution, base64 placeholder and mime type.", - nullable: true, - }) - thumbnailMedia( - @Root() article: Article, - @Ctx() ctx: RequestContext, - ) { - if (!article.thumbnailMediaId) return null - if (article.thumbnailMedia) return article.thumbnailMedia - return ctx.mediaImagesLoader.load(article.thumbnailMediaId) - } + @Arg("sort", { nullable: true }) sort: ListingsSortInput + ) { + // default sort argument + if (!sort || Object.keys(sort).length === 0) { + sort = { + price: "ASC", + } + } + + return ctx.articleActiveListingsLoader.load({ + id: article.id, + sort, + }) + } - @FieldResolver(() => [ArticleGenerativeToken], { - description: "The Generative Tokens mentionned by the article." - }) - generativeTokenMentions( - @Root() article: Article, - @Ctx() ctx: RequestContext, - ) { - return ctx.articlesGenTokMentionsLoader.load(article.id) - } + @FieldResolver(returns => MediaImage, { + description: + "The media entity associated with the thumbnail of the article, provides additional informations on the thumbnail such as resolution, base64 placeholder and mime type.", + nullable: true, + }) + thumbnailMedia(@Root() article: Article, @Ctx() ctx: RequestContext) { + if (!article.thumbnailMediaId) return null + if (article.thumbnailMedia) return article.thumbnailMedia + return ctx.mediaImagesLoader.load(article.thumbnailMediaId) + } - @FieldResolver(() => [ArticleRevision], { - description: "A list of the revisions made to the article." + @FieldResolver(() => [ArticleGenerativeToken], { + description: "The Generative Tokens mentionned by the article.", }) - revisions( + generativeTokenMentions( @Root() article: Article, - @Ctx() ctx: RequestContext, + @Ctx() ctx: RequestContext ) { + return ctx.articlesGenTokMentionsLoader.load(article.id) + } + + @FieldResolver(() => [ArticleRevision], { + description: "A list of the revisions made to the article.", + }) + revisions(@Root() article: Article, @Ctx() ctx: RequestContext) { return ctx.articlesRevisionsLoader.load(article.id) } @FieldResolver(() => [Split], { - description: "The royalty splits." + description: "The royalty splits.", }) - royaltiesSplits( - @Root() article: Article, - @Ctx() ctx: RequestContext, - ) { + royaltiesSplits(@Root() article: Article, @Ctx() ctx: RequestContext) { return ctx.articlesRoyaltiesSplitsLoader.load(article.id) } @FieldResolver(returns => [Action], { - description: "A list of all the actions related to the Article. **Not optimized to be run on multiple generative tokens at once, please use carefully*.", - }) - actions( - @Root() article: Article, - @Arg("filters", FiltersAction, { nullable: true}) filters: any, - @Arg("sort", { nullable: true }) sortArgs: ActionsSortInput, - @Args() { skip, take }: PaginationArgs, - ): Promise { - // default arguments - [skip, take] = useDefaultValues([skip, take], [0, 20]) - sortArgs = defaultSort(sortArgs, { - createdAt: "DESC" - }) - - // create the query - let query = Action.createQueryBuilder("action").select() - - // add the generic filters - query.where(processFilters(filters)) - - // add the filters to target the article only - query.andWhere("action.articleId = :id", { id: article.id }) - - // add the sort arguments - for (const sort in sortArgs) { - query.addOrderBy(`action.${sort}`, sortArgs[sort]) - } - - // add pagination - query.skip(skip) - query.take(take) - - return query.getMany() - } - - @FieldResolver(() => [Article]) - async relatedArticles( - @Root() article: Article, - @Args() { skip, take }: PaginationArgs, - ) { - // default arguments - [skip, take] = useDefaultValues([skip, take], [0, 5]) - - // sort by search relevance - const sort: ArticleSortInput = { - relevance: "DESC" - } - - // create the select query & the filters - let query = Article.createQueryBuilder("article").select() - - // apply the filters/sort - query = await articleQueryFilter( - query, - { - searchQuery_eq: article.title + " " + article.tags.join(" ") - }, - sort, - ) - - // we remove the current article from related - query.andWhere("article.id != :id", { id: article.id }) - - // add pagination - query.take(take) - query.skip(skip) - - return query.getMany() - } - - @FieldResolver(returns => String, { - nullable: true, - description: "If any, returns the moderation reason associated with the Article", - }) - async moderationReason( - @Root() article: Article, - @Ctx() ctx: RequestContext, - ) { - if (article.moderationReasonId == null) return null - if (article.moderationReason) return article.moderationReason - return ctx.moderationReasonsLoader.load(article.moderationReasonId) - } -} \ No newline at end of file + description: + "A list of all the actions related to the Article. **Not optimized to be run on multiple generative tokens at once, please use carefully*.", + }) + actions( + @Root() article: Article, + @Arg("filters", FiltersAction, { nullable: true }) filters: any, + @Arg("sort", { nullable: true }) sortArgs: ActionsSortInput, + @Args() { skip, take }: PaginationArgs + ): Promise { + // default arguments + ;[skip, take] = useDefaultValues([skip, take], [0, 20]) + sortArgs = defaultSort(sortArgs, { + createdAt: "DESC", + }) + + // create the query + let query = Action.createQueryBuilder("action").select() + + // add the generic filters + query.where(processFilters(filters)) + + // add the filters to target the article only + query.andWhere("action.articleId = :id", { id: article.id }) + + // add the sort arguments + for (const sort in sortArgs) { + query.addOrderBy(`action.${sort}`, sortArgs[sort]) + } + + // add pagination + query.skip(skip) + query.take(take) + + return query.getMany() + } + + @FieldResolver(() => [Article]) + async relatedArticles( + @Root() article: Article, + @Args() { skip, take }: PaginationArgs + ) { + // default arguments + ;[skip, take] = useDefaultValues([skip, take], [0, 5]) + + // sort by search relevance + const sort: ArticleSortInput = { + relevance: "DESC", + } + + // create the select query & the filters + let query = Article.createQueryBuilder("article").select() + + /** + * hard limit of 512 characters for the search query, so we shuffle the tags + * and add them to the query until we reach the limit + */ + const shuffledTags = shuffle(article.tags) + const searchQuery_eq = shuffledTags.reduce( + (acc, tag) => (acc.length + tag.length < 512 ? acc + " " + tag : acc), + article.title // start the query with the title + ) + + // apply the filters/sort + query = await articleQueryFilter( + query, + { + searchQuery_eq, + }, + sort + ) + + // we remove the current article from related + query.andWhere("article.id != :id", { id: article.id }) + + // add pagination + query.take(take) + query.skip(skip) + + return query.getMany() + } + + @FieldResolver(returns => String, { + nullable: true, + description: + "If any, returns the moderation reason associated with the Article", + }) + async moderationReason(@Root() article: Article, @Ctx() ctx: RequestContext) { + if (article.moderationReasonId == null) return null + if (article.moderationReason) return article.moderationReason + return ctx.moderationReasonsLoader.load(article.moderationReasonId) + } +}