mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-12-28 19:08:32 +08:00
perf: optimize friend queries (@fehmer) (#7080)
Combine two queries (first get all friend UIDs, then call leaderboard) into one query to reduce db roundtrips. Use the same approach for the friends list in user dal. Note: when updating mongodb to 6+ we could use unionWith in case we don't need the metadata (lb use-case)
This commit is contained in:
parent
81f09b9b90
commit
949e2baa48
8 changed files with 449 additions and 285 deletions
|
|
@ -11,6 +11,7 @@ import { ObjectId } from "mongodb";
|
|||
|
||||
import * as ConnectionsDal from "../../../src/dal/connections";
|
||||
import { createConnection } from "../../__testData__/connections";
|
||||
import { createUser } from "../../__testData__/users";
|
||||
|
||||
describe("ConnectionsDal", () => {
|
||||
beforeAll(async () => {
|
||||
|
|
@ -401,4 +402,92 @@ describe("ConnectionsDal", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWithAcceptedConnections", () => {
|
||||
it("should return friend uids", async () => {
|
||||
//GIVE
|
||||
const uid = (await createUser()).uid;
|
||||
const friendOne = await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const friendTwo = await createConnection({
|
||||
initiatorUid: (await createUser()).uid,
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const friendThree = await createConnection({
|
||||
initiatorUid: (await createUser()).uid,
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const _pending = await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "pending",
|
||||
});
|
||||
const _blocked = await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "blocked",
|
||||
});
|
||||
const _decoy = await createConnection({
|
||||
receiverUid: (await createUser()).uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const friendUids = await ConnectionsDal.aggregateWithAcceptedConnections<{
|
||||
uid: string;
|
||||
}>({ collectionName: "users", uid }, [{ $project: { uid: true } }]);
|
||||
|
||||
//THEN
|
||||
expect(friendUids.flatMap((it) => it.uid).toSorted()).toEqual([
|
||||
uid,
|
||||
friendOne.receiverUid,
|
||||
friendTwo.initiatorUid,
|
||||
friendThree.initiatorUid,
|
||||
]);
|
||||
});
|
||||
it("should return friend uids and metaData", async () => {
|
||||
//GIVE
|
||||
const me = await createUser();
|
||||
const friend = await createUser();
|
||||
|
||||
const connection = await createConnection({
|
||||
initiatorUid: me.uid,
|
||||
receiverUid: friend.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const friendUids = await ConnectionsDal.aggregateWithAcceptedConnections(
|
||||
{ collectionName: "users", uid: me.uid, includeMetaData: true },
|
||||
[
|
||||
{
|
||||
$project: {
|
||||
uid: true,
|
||||
lastModified: "$connectionMeta.lastModified",
|
||||
connectionId: "$connectionMeta._id",
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(friendUids).toEqual([
|
||||
{
|
||||
_id: friend._id,
|
||||
connectionId: connection._id,
|
||||
lastModified: connection.lastModified,
|
||||
uid: friend.uid,
|
||||
},
|
||||
{
|
||||
_id: me._id,
|
||||
uid: me.uid,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import _ from "lodash";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
|
|
@ -11,6 +11,7 @@ import * as DB from "../../../src/init/db";
|
|||
import { LbPersonalBests } from "../../../src/utils/pb";
|
||||
|
||||
import { pb } from "../../__testData__/users";
|
||||
import { createConnection } from "../../__testData__/connections";
|
||||
|
||||
describe("LeaderboardsDal", () => {
|
||||
afterEach(async () => {
|
||||
|
|
@ -307,9 +308,20 @@ describe("LeaderboardsDal", () => {
|
|||
it("should get for friends only", async () => {
|
||||
//GIVEN
|
||||
const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2)));
|
||||
const uid = rank1.uid;
|
||||
const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1)));
|
||||
const _rank3 = await createUser(lbBests(undefined, pb(100, 80, 2)));
|
||||
const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1)));
|
||||
|
||||
//two friends, one is not on the leaderboard
|
||||
await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: rank4.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
await createConnection({ initiatorUid: uid, status: "accepted" });
|
||||
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
//WHEN
|
||||
|
|
@ -321,7 +333,7 @@ describe("LeaderboardsDal", () => {
|
|||
0,
|
||||
50,
|
||||
false,
|
||||
[rank1.uid, rank4.uid]
|
||||
uid
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -335,11 +347,23 @@ describe("LeaderboardsDal", () => {
|
|||
it("should get for friends only with page", async () => {
|
||||
//GIVEN
|
||||
const rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2)));
|
||||
const uid = rank1.uid;
|
||||
const rank2 = await createUser(lbBests(undefined, pb(100, 90, 1)));
|
||||
const _rank3 = await createUser(lbBests(undefined, pb(95, 80, 2)));
|
||||
const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1)));
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
await createConnection({
|
||||
initiatorUid: uid,
|
||||
receiverUid: rank2.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
await createConnection({
|
||||
initiatorUid: rank4.uid,
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const results = (await LeaderboardsDal.get(
|
||||
"time",
|
||||
|
|
@ -348,7 +372,7 @@ describe("LeaderboardsDal", () => {
|
|||
1,
|
||||
2,
|
||||
false,
|
||||
[rank1.uid, rank2.uid, rank4.uid]
|
||||
uid
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
|
|
@ -360,6 +384,7 @@ describe("LeaderboardsDal", () => {
|
|||
});
|
||||
it("should return empty list if no friends", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const results = (await LeaderboardsDal.get(
|
||||
|
|
@ -369,7 +394,7 @@ describe("LeaderboardsDal", () => {
|
|||
1,
|
||||
2,
|
||||
false,
|
||||
[]
|
||||
uid
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
//THEN
|
||||
expect(results).toEqual([]);
|
||||
|
|
@ -378,10 +403,10 @@ describe("LeaderboardsDal", () => {
|
|||
describe("getCount / getRank", () => {
|
||||
it("should get count", async () => {
|
||||
//GIVEN
|
||||
await createUser(lbBests(undefined, pb(105)));
|
||||
await createUser(lbBests(undefined, pb(100)));
|
||||
const me = await createUser(lbBests(undefined, pb(95)));
|
||||
await createUser(lbBests(undefined, pb(90)));
|
||||
await createUser(lbBests(undefined, pb(105)), { name: "One" });
|
||||
await createUser(lbBests(undefined, pb(100)), { name: "Two" });
|
||||
const me = await createUser(lbBests(undefined, pb(95)), { name: "Me" });
|
||||
await createUser(lbBests(undefined, pb(90)), { name: "Three" });
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
//WHEN / THEN
|
||||
|
|
@ -405,19 +430,26 @@ describe("LeaderboardsDal", () => {
|
|||
await createUser(lbBests(undefined, pb(95)));
|
||||
const friendTwo = await createUser(lbBests(undefined, pb(90)));
|
||||
const me = await createUser(lbBests(undefined, pb(99)));
|
||||
|
||||
console.log("me", me.uid);
|
||||
|
||||
await LeaderboardsDal.update("time", "60", "english");
|
||||
|
||||
const friends = [friendOne.uid, friendTwo.uid, me.uid];
|
||||
await createConnection({
|
||||
initiatorUid: me.uid,
|
||||
receiverUid: friendOne.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
await createConnection({
|
||||
initiatorUid: friendTwo.uid,
|
||||
receiverUid: me.uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
|
||||
expect(await LeaderboardsDal.getCount("time", "60", "english", friends)) //
|
||||
expect(await LeaderboardsDal.getCount("time", "60", "english", me.uid)) //
|
||||
.toEqual(3);
|
||||
expect(
|
||||
await LeaderboardsDal.getRank("time", "60", "english", me.uid, friends)
|
||||
await LeaderboardsDal.getRank("time", "60", "english", me.uid, true)
|
||||
) //
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -16,9 +16,8 @@ export async function createConnection(
|
|||
},
|
||||
maxPerUser
|
||||
);
|
||||
await ConnectionsDal.getCollection().updateOne(
|
||||
{ _id: result._id },
|
||||
{ $set: data }
|
||||
);
|
||||
await ConnectionsDal.__testing
|
||||
.getCollection()
|
||||
.updateOne({ _id: result._id }, { $set: data });
|
||||
return { ...result, ...data };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,10 @@ describe("Loaderboard Controller", () => {
|
|||
describe("get leaderboard", () => {
|
||||
const getLeaderboardMock = vi.spyOn(LeaderboardDal, "get");
|
||||
const getLeaderboardCountMock = vi.spyOn(LeaderboardDal, "getCount");
|
||||
const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids");
|
||||
|
||||
beforeEach(() => {
|
||||
getLeaderboardMock.mockClear();
|
||||
getLeaderboardCountMock.mockClear();
|
||||
getFriendsUidsMock.mockClear();
|
||||
getLeaderboardCountMock.mockResolvedValue(42);
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +152,6 @@ describe("Loaderboard Controller", () => {
|
|||
//GIVEN
|
||||
await enableConnectionsFeature(true);
|
||||
getLeaderboardMock.mockResolvedValue([]);
|
||||
getFriendsUidsMock.mockResolvedValue(["uidOne", "uidTwo"]);
|
||||
getLeaderboardCountMock.mockResolvedValue(2);
|
||||
|
||||
//WHEN
|
||||
|
|
@ -180,13 +177,13 @@ describe("Loaderboard Controller", () => {
|
|||
0,
|
||||
50,
|
||||
false,
|
||||
["uidOne", "uidTwo"]
|
||||
uid
|
||||
);
|
||||
expect(getLeaderboardCountMock).toHaveBeenCalledWith(
|
||||
"time",
|
||||
"60",
|
||||
"english",
|
||||
["uidOne", "uidTwo"]
|
||||
uid
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -286,11 +283,9 @@ describe("Loaderboard Controller", () => {
|
|||
|
||||
describe("get rank", () => {
|
||||
const getLeaderboardRankMock = vi.spyOn(LeaderboardDal, "getRank");
|
||||
const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids");
|
||||
|
||||
afterEach(() => {
|
||||
getLeaderboardRankMock.mockClear();
|
||||
getFriendsUidsMock.mockClear();
|
||||
});
|
||||
|
||||
it("fails withouth authentication", async () => {
|
||||
|
|
@ -335,14 +330,12 @@ describe("Loaderboard Controller", () => {
|
|||
"60",
|
||||
"english",
|
||||
uid,
|
||||
undefined
|
||||
false
|
||||
);
|
||||
});
|
||||
it("should get for english time 60 friends only", async () => {
|
||||
//GIVEN
|
||||
await enableConnectionsFeature(true);
|
||||
const friends = ["friendOne", "friendTwo"];
|
||||
getFriendsUidsMock.mockResolvedValue(friends);
|
||||
getLeaderboardRankMock.mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
|
|
@ -363,9 +356,8 @@ describe("Loaderboard Controller", () => {
|
|||
"60",
|
||||
"english",
|
||||
uid,
|
||||
friends
|
||||
true
|
||||
);
|
||||
expect(getFriendsUidsMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
it("should get with ape key", async () => {
|
||||
await acceptApeKeys(true);
|
||||
|
|
|
|||
|
|
@ -43,11 +43,7 @@ export async function getLeaderboard(
|
|||
throw new MonkeyError(404, "There is no leaderboard for this mode");
|
||||
}
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig
|
||||
);
|
||||
const friendsOnlyUid = getFriendsOnlyUid(uid, friendsOnly, connectionsConfig);
|
||||
|
||||
const leaderboard = await LeaderboardsDAL.get(
|
||||
mode,
|
||||
|
|
@ -56,7 +52,7 @@ export async function getLeaderboard(
|
|||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.users.premium.enabled,
|
||||
friendUids
|
||||
friendsOnlyUid
|
||||
);
|
||||
|
||||
if (leaderboard === false) {
|
||||
|
|
@ -70,7 +66,7 @@ export async function getLeaderboard(
|
|||
mode,
|
||||
mode2,
|
||||
language,
|
||||
friendUids
|
||||
friendsOnlyUid
|
||||
);
|
||||
const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"]));
|
||||
|
||||
|
|
@ -88,18 +84,12 @@ export async function getRankFromLeaderboard(
|
|||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig
|
||||
);
|
||||
|
||||
const data = await LeaderboardsDAL.getRank(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
uid,
|
||||
friendUids
|
||||
getFriendsOnlyUid(uid, friendsOnly, connectionsConfig) !== undefined
|
||||
);
|
||||
if (data === false) {
|
||||
throw new MonkeyError(
|
||||
|
|
@ -284,3 +274,17 @@ async function getFriendsUids(
|
|||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFriendsOnlyUid(
|
||||
uid: string,
|
||||
friendsOnly: boolean | undefined,
|
||||
friendsConfig: Configuration["connections"]
|
||||
): string | undefined {
|
||||
if (uid !== "" && friendsOnly === true) {
|
||||
if (!friendsConfig.enabled) {
|
||||
throw new MonkeyError(503, "This feature is currently unavailable.");
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Collection, Filter, ObjectId } from "mongodb";
|
||||
import { Collection, Document, Filter, ObjectId } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections";
|
||||
import MonkeyError from "../utils/error";
|
||||
|
|
@ -10,7 +10,7 @@ export type DBConnection = WithObjectId<
|
|||
}
|
||||
>;
|
||||
|
||||
export const getCollection = (): Collection<DBConnection> =>
|
||||
const getCollection = (): Collection<DBConnection> =>
|
||||
db.collection("connections");
|
||||
|
||||
export async function getConnections(options: {
|
||||
|
|
@ -209,6 +209,125 @@ export async function getFriendsUids(uid: string): Promise<string[]> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* aggregate the given `pipeline` on the `collectionName` for each friendUid and the given `uid`.
|
||||
|
||||
* @param pipeline
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export async function aggregateWithAcceptedConnections<T>(
|
||||
options: {
|
||||
uid: string;
|
||||
/**
|
||||
* target collection
|
||||
*/
|
||||
collectionName: string;
|
||||
/**
|
||||
* uid field on the collection, defaults to `uid`
|
||||
*/
|
||||
uidField?: string;
|
||||
/**
|
||||
* add meta data `connectionMeta.lastModified` and *connectionMeta._id` to the document
|
||||
*/
|
||||
includeMetaData?: boolean;
|
||||
},
|
||||
pipeline: Document[]
|
||||
): Promise<T[]> {
|
||||
const metaData = options.includeMetaData
|
||||
? {
|
||||
let: {
|
||||
lastModified: "$lastModified",
|
||||
connectionId: "$connectionId",
|
||||
},
|
||||
pipeline: [
|
||||
{
|
||||
$addFields: {
|
||||
"connectionMeta.lastModified": "$$lastModified",
|
||||
"connectionMeta._id": "$$connectionId",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const { uid, collectionName, uidField } = options;
|
||||
const fullPipeline = [
|
||||
{
|
||||
$match: {
|
||||
status: "accepted",
|
||||
//uid is friend or initiator
|
||||
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
lastModified: true,
|
||||
uid: {
|
||||
//pick the other user, not uid
|
||||
$cond: {
|
||||
if: { $eq: ["$receiverUid", uid] },
|
||||
// oxlint-disable-next-line no-thenable
|
||||
then: "$initiatorUid",
|
||||
else: "$receiverUid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// we want to fetch the data for our uid as well, add it to the list of documents
|
||||
// workaround for missing unionWith + $documents in mongodb 5.0
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
data: {
|
||||
$push: {
|
||||
uid: "$uid",
|
||||
lastModified: "$lastModified",
|
||||
connectionId: "$_id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
data: {
|
||||
$concatArrays: ["$data", [{ uid }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $unwind: "$data" },
|
||||
{ $replaceRoot: { newRoot: "$data" } },
|
||||
|
||||
/* end of workaround, this is the replacement for >= 5.1
|
||||
|
||||
{ $addFields: { connectionId: "$_id" } },
|
||||
{ $project: { uid: true, lastModified: true, connectionId: true } },
|
||||
{
|
||||
$unionWith: {
|
||||
pipeline: [{ $documents: [{ uid }] }],
|
||||
},
|
||||
},
|
||||
*/
|
||||
|
||||
{
|
||||
//replace with $unionWith in MongoDB 6 or newer
|
||||
$lookup: {
|
||||
from: collectionName,
|
||||
localField: "uid",
|
||||
foreignField: uidField ?? "uid",
|
||||
as: "result",
|
||||
...metaData,
|
||||
},
|
||||
},
|
||||
|
||||
{ $match: { result: { $ne: [] } } },
|
||||
{ $replaceRoot: { newRoot: { $first: "$result" } } },
|
||||
...pipeline,
|
||||
];
|
||||
|
||||
//console.log(JSON.stringify(fullPipeline, null, 4));
|
||||
return (await getCollection().aggregate(fullPipeline).toArray()) as T[];
|
||||
}
|
||||
|
||||
function getKey(initiatorUid: string, receiverUid: string): string {
|
||||
const ids = [initiatorUid, receiverUid];
|
||||
ids.sort();
|
||||
|
|
@ -223,3 +342,7 @@ export async function createIndicies(): Promise<void> {
|
|||
//make sure there is only one connection for each initiatorr/receiver
|
||||
await getCollection().createIndex({ key: 1 }, { unique: true });
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
getCollection,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,19 +14,25 @@ import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards";
|
|||
import { omit } from "lodash";
|
||||
import { DBUser, getUsersCollection } from "./user";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { aggregateWithAcceptedConnections } from "./connections";
|
||||
|
||||
export type DBLeaderboardEntry = LeaderboardEntry & {
|
||||
_id: ObjectId;
|
||||
};
|
||||
|
||||
function getCollectionName(key: {
|
||||
language: string;
|
||||
mode: string;
|
||||
mode2: string;
|
||||
}): string {
|
||||
return `leaderboards.${key.language}.${key.mode}.${key.mode2}`;
|
||||
}
|
||||
export const getCollection = (key: {
|
||||
language: string;
|
||||
mode: string;
|
||||
mode2: string;
|
||||
}): Collection<DBLeaderboardEntry> =>
|
||||
db.collection<DBLeaderboardEntry>(
|
||||
`leaderboards.${key.language}.${key.mode}.${key.mode2}`
|
||||
);
|
||||
db.collection<DBLeaderboardEntry>(getCollectionName(key));
|
||||
|
||||
export async function get(
|
||||
mode: string,
|
||||
|
|
@ -35,42 +41,45 @@ export async function get(
|
|||
page: number,
|
||||
pageSize: number,
|
||||
premiumFeaturesEnabled: boolean = false,
|
||||
userIds?: string[]
|
||||
uid?: string
|
||||
): Promise<DBLeaderboardEntry[] | false> {
|
||||
if (page < 0 || pageSize < 0) {
|
||||
throw new MonkeyError(500, "Invalid page or pageSize");
|
||||
}
|
||||
|
||||
if (userIds?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skip = page * pageSize;
|
||||
const limit = pageSize;
|
||||
|
||||
let leaderboard: DBLeaderboardEntry[] | false = [];
|
||||
|
||||
const pipeline: Document[] = [
|
||||
{ $sort: { rank: 1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
];
|
||||
|
||||
if (userIds !== undefined) {
|
||||
pipeline.unshift(
|
||||
{ $match: { uid: { $in: userIds } } },
|
||||
{
|
||||
$setWindowFields: {
|
||||
sortBy: { rank: 1 },
|
||||
output: { friendsRank: { $documentNumber: {} } },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let leaderboard = (await getCollection({ language, mode, mode2 })
|
||||
.aggregate(pipeline)
|
||||
.toArray()) as DBLeaderboardEntry[];
|
||||
|
||||
if (uid !== undefined) {
|
||||
leaderboard = await aggregateWithAcceptedConnections(
|
||||
{
|
||||
uid,
|
||||
collectionName: getCollectionName({ language, mode, mode2 }),
|
||||
},
|
||||
[
|
||||
{
|
||||
$setWindowFields: {
|
||||
sortBy: { rank: 1 },
|
||||
output: { friendsRank: { $documentNumber: {} } },
|
||||
},
|
||||
},
|
||||
...pipeline,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
leaderboard = await getCollection({ language, mode, mode2 })
|
||||
.aggregate<DBLeaderboardEntry>(pipeline)
|
||||
.toArray();
|
||||
}
|
||||
if (!premiumFeaturesEnabled) {
|
||||
leaderboard = leaderboard.map((it) => omit(it, "isPremium"));
|
||||
}
|
||||
|
|
@ -92,23 +101,30 @@ export async function getCount(
|
|||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
userIds?: string[]
|
||||
uid?: string
|
||||
): Promise<number> {
|
||||
const key = `${language}_${mode}_${mode2}`;
|
||||
if (userIds === undefined && cachedCounts.has(key)) {
|
||||
if (uid === undefined && cachedCounts.has(key)) {
|
||||
return cachedCounts.get(key) as number;
|
||||
} else {
|
||||
const lb = getCollection({
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
});
|
||||
if (userIds === undefined) {
|
||||
const count = await lb.estimatedDocumentCount();
|
||||
if (uid === undefined) {
|
||||
const count = await getCollection({
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
}).estimatedDocumentCount();
|
||||
cachedCounts.set(key, count);
|
||||
return count;
|
||||
} else {
|
||||
return lb.countDocuments({ uid: { $in: userIds } });
|
||||
return (
|
||||
await aggregateWithAcceptedConnections(
|
||||
{
|
||||
collectionName: getCollectionName({ language, mode, mode2 }),
|
||||
uid,
|
||||
},
|
||||
[{ $project: { _id: true } }]
|
||||
)
|
||||
).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -118,32 +134,33 @@ export async function getRank(
|
|||
mode2: string,
|
||||
language: string,
|
||||
uid: string,
|
||||
userIds?: string[]
|
||||
friendsOnly: boolean = false
|
||||
): Promise<LeaderboardEntry | null | false> {
|
||||
try {
|
||||
if (userIds === undefined) {
|
||||
if (!friendsOnly) {
|
||||
const entry = await getCollection({ language, mode, mode2 }).findOne({
|
||||
uid,
|
||||
});
|
||||
|
||||
return entry;
|
||||
} else if (userIds.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
const entry = await getCollection({ language, mode, mode2 })
|
||||
.aggregate([
|
||||
{ $match: { uid: { $in: userIds } } },
|
||||
const results =
|
||||
await aggregateWithAcceptedConnections<DBLeaderboardEntry>(
|
||||
{
|
||||
$setWindowFields: {
|
||||
sortBy: { rank: 1 },
|
||||
output: { friendsRank: { $documentNumber: {} } },
|
||||
},
|
||||
collectionName: getCollectionName({ language, mode, mode2 }),
|
||||
uid,
|
||||
},
|
||||
{ $match: { uid } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
return entry[0] as DBLeaderboardEntry;
|
||||
[
|
||||
{
|
||||
$setWindowFields: {
|
||||
sortBy: { rank: 1 },
|
||||
output: { friendsRank: { $documentNumber: {} } },
|
||||
},
|
||||
},
|
||||
{ $match: { uid } },
|
||||
]
|
||||
);
|
||||
return results[0] ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
|
|
@ -164,7 +181,7 @@ export async function update(
|
|||
rank?: number;
|
||||
}> {
|
||||
const key = `lbPersonalBests.${mode}.${mode2}.${language}`;
|
||||
const lbCollectionName = `leaderboards.${language}.${mode}.${mode2}`;
|
||||
const lbCollectionName = getCollectionName({ language, mode, mode2 });
|
||||
const minTimeTyping = (await getCachedConfiguration(true)).leaderboards
|
||||
.minTimeTyping;
|
||||
const lb = db.collection<DBUser>("users").aggregate<LeaderboardEntry>(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { Result as ResultType } from "@monkeytype/schemas/results";
|
|||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { isToday, isYesterday } from "@monkeytype/util/date-and-time";
|
||||
import GeorgeQueue from "../queues/george-queue";
|
||||
import { getCollection as getConnectionCollection } from "./connections";
|
||||
import { aggregateWithAcceptedConnections } from "./connections";
|
||||
|
||||
export type DBUserTag = WithObjectId<UserTag>;
|
||||
|
||||
|
|
@ -1228,210 +1228,118 @@ async function updateUser(
|
|||
}
|
||||
|
||||
export async function getFriends(uid: string): Promise<DBFriend[]> {
|
||||
return (await getConnectionCollection()
|
||||
.aggregate([
|
||||
{
|
||||
$match: {
|
||||
//uid is friend or initiator
|
||||
$and: [
|
||||
{
|
||||
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
|
||||
status: "accepted",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
return await aggregateWithAcceptedConnections(
|
||||
{
|
||||
uid,
|
||||
collectionName: "users",
|
||||
includeMetaData: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
$project: {
|
||||
receiverUid: true,
|
||||
initiatorUid: true,
|
||||
lastModified: true,
|
||||
_id: false,
|
||||
uid: true,
|
||||
connectionId: "$connectionMeta._id",
|
||||
lastModified: "$connectionMeta.lastModified",
|
||||
name: true,
|
||||
discordId: true,
|
||||
discordAvatar: true,
|
||||
startedTests: true,
|
||||
completedTests: true,
|
||||
timeTyping: true,
|
||||
xp: true,
|
||||
"streak.length": true,
|
||||
"streak.maxLength": true,
|
||||
personalBests: true,
|
||||
"inventory.badges": true,
|
||||
"premium.expirationTimestamp": true,
|
||||
banned: 1,
|
||||
lbOptOut: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
//pick the other user, not uid
|
||||
uid: {
|
||||
top15: {
|
||||
$reduce: {
|
||||
//find highest wpm from time 15 PBs
|
||||
input: "$personalBests.time.15",
|
||||
initialValue: {},
|
||||
in: {
|
||||
$cond: [
|
||||
{ $gte: ["$$this.wpm", "$$value.wpm"] },
|
||||
"$$this",
|
||||
"$$value",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
top60: {
|
||||
$reduce: {
|
||||
//find highest wpm from time 60 PBs
|
||||
input: "$personalBests.time.60",
|
||||
initialValue: {},
|
||||
in: {
|
||||
$cond: [
|
||||
{ $gte: ["$$this.wpm", "$$value.wpm"] },
|
||||
"$$this",
|
||||
"$$value",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
badgeId: {
|
||||
$ifNull: [
|
||||
{
|
||||
$first: {
|
||||
$map: {
|
||||
input: {
|
||||
$filter: {
|
||||
input: "$inventory.badges",
|
||||
as: "badge",
|
||||
cond: { $eq: ["$$badge.selected", true] },
|
||||
},
|
||||
},
|
||||
as: "selectedBadge",
|
||||
in: "$$selectedBadge.id",
|
||||
},
|
||||
},
|
||||
},
|
||||
"$$REMOVE",
|
||||
],
|
||||
},
|
||||
isPremium: {
|
||||
$cond: {
|
||||
if: { $eq: ["$receiverUid", uid] },
|
||||
if: {
|
||||
$or: [
|
||||
{ $eq: ["$premium.expirationTimestamp", -1] },
|
||||
{
|
||||
$gt: ["$premium.expirationTimestamp", { $toLong: "$$NOW" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
// oxlint-disable-next-line no-thenable
|
||||
then: "$initiatorUid",
|
||||
else: "$receiverUid",
|
||||
then: true,
|
||||
else: "$$REMOVE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// we want to fetch the data for our uid as well, add it to the list of documents
|
||||
// workaround for missing unionWith + $documents in mongodb 5.0
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
data: {
|
||||
$push: {
|
||||
uid: "$uid",
|
||||
lastModified: "$lastModified",
|
||||
connectionId: "$_id",
|
||||
},
|
||||
},
|
||||
$addFields: {
|
||||
//remove nulls
|
||||
top15: { $ifNull: ["$top15", "$$REMOVE"] },
|
||||
top60: { $ifNull: ["$top60", "$$REMOVE"] },
|
||||
badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] },
|
||||
lastModified: "$lastModified",
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
data: {
|
||||
$concatArrays: ["$data", [{ uid }]],
|
||||
},
|
||||
personalBests: false,
|
||||
inventory: false,
|
||||
premium: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
$unwind: "$data",
|
||||
},
|
||||
|
||||
/* end of workaround, this is the replacement for >= 5.1
|
||||
|
||||
{ $addFields: { connectionId: "$_id" } },
|
||||
{ $project: { uid: true, lastModified: true, connectionId: true } },
|
||||
{
|
||||
$unionWith: {
|
||||
pipeline: [{ $documents: [{ uid }] }],
|
||||
},
|
||||
},
|
||||
*/
|
||||
|
||||
{
|
||||
$lookup: {
|
||||
/* query users to get the friend data */
|
||||
from: "users",
|
||||
localField: "data.uid", //just uid if we remove the workaround above
|
||||
foreignField: "uid",
|
||||
as: "result",
|
||||
let: {
|
||||
lastModified: "$data.lastModified", //just $lastModified if we remove the workaround above
|
||||
connectionId: "$data.connectionId", //just $connectionId if we remove the workaround above
|
||||
},
|
||||
pipeline: [
|
||||
{
|
||||
$project: {
|
||||
_id: false,
|
||||
uid: true,
|
||||
connectionId: true,
|
||||
name: true,
|
||||
discordId: true,
|
||||
discordAvatar: true,
|
||||
startedTests: true,
|
||||
completedTests: true,
|
||||
timeTyping: true,
|
||||
xp: true,
|
||||
"streak.length": true,
|
||||
"streak.maxLength": true,
|
||||
personalBests: true,
|
||||
"inventory.badges": true,
|
||||
"premium.expirationTimestamp": true,
|
||||
banned: 1,
|
||||
lbOptOut: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
lastModified: "$$lastModified",
|
||||
connectionId: "$$connectionId",
|
||||
top15: {
|
||||
$reduce: {
|
||||
//find highest wpm from time 15 PBs
|
||||
input: "$personalBests.time.15",
|
||||
initialValue: {},
|
||||
in: {
|
||||
$cond: [
|
||||
{ $gte: ["$$this.wpm", "$$value.wpm"] },
|
||||
"$$this",
|
||||
"$$value",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
top60: {
|
||||
$reduce: {
|
||||
//find highest wpm from time 60 PBs
|
||||
input: "$personalBests.time.60",
|
||||
initialValue: {},
|
||||
in: {
|
||||
$cond: [
|
||||
{ $gte: ["$$this.wpm", "$$value.wpm"] },
|
||||
"$$this",
|
||||
"$$value",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
badgeId: {
|
||||
$ifNull: [
|
||||
{
|
||||
$first: {
|
||||
$map: {
|
||||
input: {
|
||||
$filter: {
|
||||
input: "$inventory.badges",
|
||||
as: "badge",
|
||||
cond: { $eq: ["$$badge.selected", true] },
|
||||
},
|
||||
},
|
||||
as: "selectedBadge",
|
||||
in: "$$selectedBadge.id",
|
||||
},
|
||||
},
|
||||
},
|
||||
"$$REMOVE",
|
||||
],
|
||||
},
|
||||
isPremium: {
|
||||
$cond: {
|
||||
if: {
|
||||
$or: [
|
||||
{ $eq: ["$premium.expirationTimestamp", -1] },
|
||||
{
|
||||
$gt: [
|
||||
"$premium.expirationTimestamp",
|
||||
{ $toLong: "$$NOW" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// oxlint-disable-next-line no-thenable
|
||||
then: true,
|
||||
else: "$$REMOVE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
//remove nulls
|
||||
top15: { $ifNull: ["$top15", "$$REMOVE"] },
|
||||
top60: { $ifNull: ["$top60", "$$REMOVE"] },
|
||||
badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] },
|
||||
lastModified: "$lastModified",
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
personalBests: false,
|
||||
inventory: false,
|
||||
premium: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceRoot: {
|
||||
newRoot: {
|
||||
$cond: [
|
||||
{ $gt: [{ $size: "$result" }, 0] },
|
||||
{ $first: "$result" },
|
||||
{}, // empty document fallback, this can happen if the user is not present
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
.toArray()) as DBFriend[];
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue