Skip to content

Commit ee137c9

Browse files
committed
feat: pagination endpoints for areas and climbs
TODO: * write unit tests
1 parent 97036cc commit ee137c9

4 files changed

Lines changed: 221 additions & 64 deletions

File tree

src/db/MediaObjectTypes.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,32 @@ export interface TagsLeaderboardType {
5858
*/
5959
export type NewMediaObjectDoc = Omit<MediaObject, '_id' | 'createdAt'>
6060

61-
export interface UserMediaGQLQueryInput {
62-
userUuid: string
61+
/**
62+
* GQL input type for getting paginated media for an "Entity", which is either a user, an area, or a climb.
63+
* The userUuid is omitted from the Area and Climb versions of this type, which are defined below
64+
* as AreaMediaQueryInput and ClimbMediaQueryInput
65+
* @param maxFiles - the maximum number of media files to return
66+
* @param first - the number of media files to return
67+
* @param after - the cursor to start from
68+
*/
69+
export interface EntityMediaGQLQueryInput {
6370
maxFiles?: number
6471
first?: number
6572
after?: string
6673
}
6774

68-
export type UserMediaQueryInput = Omit<UserMediaGQLQueryInput, 'userUuid'> & {
75+
export type UserMediaQueryInput = EntityMediaGQLQueryInput & {
6976
userUuid: MUUID
7077
}
7178

79+
export type AreaMediaQueryInput = EntityMediaGQLQueryInput & {
80+
areaUuid: MUUID
81+
}
82+
83+
export type ClimbMediaQueryInput = EntityMediaGQLQueryInput & {
84+
climbUuid: MUUID
85+
}
86+
7287
/**
7388
* GQL user input type for remove tag mutation
7489
*/
@@ -123,6 +138,14 @@ export interface UserMedia {
123138
}
124139
}
125140

141+
export type AreaMedia = Omit<UserMedia, 'userUuid'> & {
142+
areaUuid: string
143+
}
144+
145+
export type ClimbMedia = Omit<UserMedia, 'userUuid'> & {
146+
climbUuid: string
147+
}
148+
126149
interface MediaEdge {
127150
node: MediaObject
128151
cursor: string

src/graphql/media/queries.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import mongoose from 'mongoose'
22
import muuid from 'uuid-mongodb'
3-
import { TagsLeaderboardType, MediaObject, MediaByUsers, UserMediaGQLQueryInput, MediaForFeedInput } from '../../db/MediaObjectTypes.js'
3+
import { TagsLeaderboardType, MediaObject, MediaByUsers, UserMediaQueryInput, AreaMediaQueryInput, ClimbMediaQueryInput, MediaForFeedInput } from '../../db/MediaObjectTypes.js'
44
import { GQLContext } from '../../types.js'
55

66
const MediaQueries = {
@@ -19,16 +19,28 @@ const MediaQueries = {
1919

2020
getUserMedia: async (_: any, { input }, { dataSources }: GQLContext): Promise<MediaObject[]> => {
2121
const { media } = dataSources
22-
const { userUuid, maxFiles = 1000 } = input as UserMediaGQLQueryInput
23-
return await media.getOneUserMedia(userUuid, maxFiles)
22+
const { userUuid, maxFiles = 1000 } = input as UserMediaQueryInput
23+
return await media.getOneUserMedia(userUuid.toString(), maxFiles)
2424
},
2525

2626
getUserMediaPagination: async (_: any, { input }, { dataSources }: GQLContext): Promise<any> => {
2727
const { media } = dataSources
28-
const { userUuid } = input as UserMediaGQLQueryInput
28+
const { userUuid } = input as UserMediaQueryInput
2929
return await media.getOneUserMediaPagination({ ...input, userUuid: muuid.from(userUuid) })
3030
},
3131

32+
areaMediaPagination: async (_: any, { input }, { dataSources }: GQLContext): Promise<any> => {
33+
const { media } = dataSources
34+
const { areaUuid } = input as AreaMediaQueryInput
35+
return await media.getOneAreaMediaPagination({ ...input, areaUuid: muuid.from(areaUuid) })
36+
},
37+
38+
climbMediaPagination: async (_: any, { input }, { dataSources }: GQLContext): Promise<any> => {
39+
const { media } = dataSources
40+
const { climbUuid } = input as ClimbMediaQueryInput
41+
return await media.getOneClimbMediaPagination({ ...input, climbUuid: muuid.from(climbUuid) })
42+
},
43+
3244
getTagsLeaderboard: async (_, { limit = 30 }: { limit: number }, { dataSources }: GQLContext): Promise<TagsLeaderboardType> => {
3345
const { media } = dataSources
3446
return await media.getTagsLeaderboard(limit)

src/graphql/schema/Media.gql

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ type Query {
4848
"""
4949
getUserMediaPagination(input: UserMediaInput): UserMedia
5050

51+
"""
52+
Get media cursor with paginationg for an area
53+
"""
54+
areaMediaPagination(input: AreaMediaInput): AreaMedia
55+
56+
"""
57+
Get media cursor with paginationg for a climb
58+
"""
59+
climbMediaPagination(input: ClimbMediaInput): ClimbMedia
60+
5161
"""
5262
Get a list of users and their tagged photo count.
5363
"""
@@ -63,6 +73,24 @@ type UserMedia {
6373
mediaConnection: MediaConnection!
6474
}
6575

76+
"""
77+
Area media cursor with pagination support.
78+
See https://graphql.org/learn/pagination/
79+
"""
80+
type AreaMedia {
81+
areaUuid: ID!
82+
mediaConnection: MediaConnection!
83+
}
84+
85+
"""
86+
Climb media cursor with pagination support.
87+
See https://graphql.org/learn/pagination/
88+
"""
89+
type ClimbMedia {
90+
climbUuid: ID!
91+
mediaConnection: MediaConnection!
92+
}
93+
6694
"""
6795
Media connection.
6896
See https://graphql.org/learn/pagination/
@@ -230,6 +258,29 @@ input UserMediaInput {
230258
after: ID
231259
}
232260

261+
"Input parameters for area and climb media queries."
262+
input AreaMediaInput {
263+
"Area UUID Ex: 5f5b4b4b-0b3b-4b3b-8b3b-7b3b2b3b2b3b"
264+
areaUuid: ID!
265+
"Max number of objects return. Ignore when using with pagination query."
266+
maxFiles: Int
267+
"Number of objects per page (Default = 6)."
268+
first: Int
269+
"Returning page data after this cursor (exclusive). Return the first page if omitted."
270+
after: ID
271+
}
272+
273+
"Input parameters for climb media queries."
274+
input ClimbMediaInput {
275+
"Climb UUID Ex: 5f5b4b4b-0b3b-4b3b-8b3b-7b3b2b3b2b3b"
276+
climbUuid: ID!
277+
"Max number of objects return. Ignore when using with pagination query."
278+
maxFiles: Int
279+
"Number of objects per page (Default = 6)."
280+
first: Int
281+
"Returning page data after this cursor (exclusive). Return the first page if omitted."
282+
after: ID
283+
}
233284
input MediaForFeedInput {
234285
maxUsers: Int
235286
maxFiles: Int

src/model/MediaDataSource.ts

Lines changed: 128 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import muid, { MUUID } from 'uuid-mongodb'
33
import mongoose from 'mongoose'
44
import { logger } from '../logger.js'
55
import { getMediaObjectModel } from '../db/index.js'
6-
import { TagsLeaderboardType, UserMediaQueryInput, AllTimeTagStats, MediaByUsers, MediaForFeedInput, MediaObject, UserMedia } from '../db/MediaObjectTypes.js'
6+
import { TagsLeaderboardType, UserMediaQueryInput, AreaMediaQueryInput, ClimbMediaQueryInput, AllTimeTagStats, MediaByUsers, MediaForFeedInput, MediaObject, UserMedia, AreaMedia, ClimbMedia } from '../db/MediaObjectTypes.js'
77

88
const HARD_MAX_FILES = 1000
99
const HARD_MAX_USERS = 100
@@ -127,71 +127,64 @@ export default class MediaDataSource extends MongoDataSource<MediaObject> {
127127
*/
128128
async getOneUserMediaPagination (input: UserMediaQueryInput): Promise<UserMedia> {
129129
const { userUuid, first = 6, after } = input
130-
let nextCreatedDate: number
131-
let nextId: mongoose.Types.ObjectId
132-
let filters: any
133-
if (after != null) {
134-
const d = after.split('_')
135-
nextCreatedDate = Number.parseInt(d[0])
136-
nextId = new mongoose.Types.ObjectId(d[1])
137-
filters = {
138-
$match: {
139-
$and: [
140-
{ userUuid },
141-
{
142-
$or: [{
143-
createdAt: { $lt: new Date(nextCreatedDate) }
144-
},
145-
{
146-
// If the created date is an exact match, we need a tiebreaker,
147-
// so we use the _id field from the cursor.
148-
createdAt: new Date(nextCreatedDate),
149-
_id: { $lt: nextId }
150-
}
151-
]
152-
}
153-
]
154-
}
155-
}
156-
} else {
157-
filters = { $match: { userUuid } }
130+
const filters = this.mediaFilters(after, userUuid, 'user')
131+
const filteredMedia = await this.aggregateMedia(filters, first)
132+
const itemCount = await this.mediaObjectModel.countDocuments({ userUuid })
133+
let hasNextPage = false
134+
if (filteredMedia.length > first) {
135+
// ok there's a next page. remove the extra item.
136+
filteredMedia.pop()
137+
hasNextPage = true
158138
}
159139

160-
const rs = await this.mediaObjectModel.aggregate<MediaObject>([
161-
filters,
162-
{
163-
$sort: { createdAt: -1, _id: -1 }
164-
},
165-
{
166-
$limit: first + 1 // fetch 1 extra to see if there's a next page
167-
}
168-
])
140+
return {
141+
userUuid: userUuid.toUUID().toString(),
142+
mediaConnection: this.getMediaConnection(filteredMedia, itemCount, hasNextPage)
143+
}
144+
}
169145

170-
const itemCount = await this.mediaObjectModel.countDocuments({ userUuid })
146+
/**
147+
* Get Area media by page.
148+
*
149+
* See
150+
* - https://engage.so/blog/a-deep-dive-into-offset-and-cursor-based-pagination-in-mongodb/#what-is-cursor-based-pagination
151+
* - https://www.mixmax.com/engineering/api-paging-built-the-right-way
152+
* - https://graphql.org/learn/pagination/
153+
* @param input
154+
* @returns
155+
*/
171156

157+
async getOneAreaMediaPagination (input: AreaMediaQueryInput): Promise<AreaMedia> {
158+
const { areaUuid, first = 6, after } = input
159+
const filters = this.mediaFilters(after, areaUuid, 'area')
160+
const filteredMedia = await this.aggregateMedia(filters, first)
161+
const itemCount = await this.mediaObjectModel.countDocuments({ 'entityTags.ancestors': { $regex: areaUuid.toUUID().toString() } })
172162
let hasNextPage = false
173-
if (rs.length > first) {
174-
// ok there's a next page. remove the extra item.
175-
rs.pop()
163+
if (filteredMedia.length > first) {
164+
filteredMedia.pop()
176165
hasNextPage = true
177166
}
178167

179168
return {
180-
userUuid: userUuid.toUUID().toString(),
181-
mediaConnection: {
182-
edges: rs.map(node => (
183-
{
184-
node,
185-
cursor: `${node.createdAt.getTime()}_${node._id.toString()}`
186-
}
187-
)),
188-
pageInfo: {
189-
hasNextPage,
190-
totalItems: itemCount,
191-
endCursor: null
192-
}
169+
areaUuid: areaUuid.toUUID().toString(),
170+
mediaConnection: this.getMediaConnection(filteredMedia, itemCount, hasNextPage)
171+
}
172+
}
193173

194-
}
174+
async getOneClimbMediaPagination (input: ClimbMediaQueryInput): Promise<ClimbMedia> {
175+
const { climbUuid, first = 6, after } = input
176+
const filters = this.mediaFilters(after, climbUuid, 'climb')
177+
const filteredMedia = await this.aggregateMedia(filters, first)
178+
const itemCount = await this.mediaObjectModel.countDocuments({ 'entityTags.targetId': muid.from(climbUuid) })
179+
let hasNextPage = false
180+
if (filteredMedia.length > first) {
181+
filteredMedia.pop()
182+
hasNextPage = true
183+
}
184+
185+
return {
186+
climbUuid: climbUuid.toUUID().toString(),
187+
mediaConnection: this.getMediaConnection(filteredMedia, itemCount, hasNextPage)
195188
}
196189
}
197190

@@ -270,7 +263,7 @@ export default class MediaDataSource extends MongoDataSource<MediaObject> {
270263
*
271264
* @param areaId
272265
* @param ancestors
273-
* @returns `UserMediaWithTags` array
266+
* @returns `MediaWithTags` array
274267
*/
275268
async findMediaByAreaId (areaId: MUUID, projection: any, shouldConvertUuidToString = false): Promise<MediaObject[]> {
276269
const transformFn = (doc: MediaObject): any => {
@@ -304,4 +297,82 @@ export default class MediaDataSource extends MongoDataSource<MediaObject> {
304297
const doc = await this.mediaObjectModel.find({ _id, userUuid }, { _id: 1 }).lean()
305298
return doc != null
306299
}
300+
301+
/**
302+
* helper functions for quering media objects by user, area, or climb. Each query has slightly different syntax,
303+
* so these private functions return the appropriate filter for the query.
304+
* @param entityUuid area, climb or user uuid
305+
* @param entityType 'area', 'climb', or 'user'
306+
* @returns
307+
*/
308+
309+
private mediaFilters (after: string | undefined, entityUuid: MUUID, entityType: string): any {
310+
let nextCreatedDate: number
311+
let nextId: mongoose.Types.ObjectId
312+
let filters: any
313+
const matchClause = this.getMatchClause(entityUuid, entityType)
314+
315+
if (after != null) {
316+
const d = after.split('_')
317+
nextCreatedDate = Number.parseInt(d[0])
318+
nextId = new mongoose.Types.ObjectId(d[1])
319+
filters = {
320+
$match: {
321+
$and: [
322+
matchClause,
323+
{
324+
$or: [{
325+
createdAt: { $lt: new Date(nextCreatedDate) }
326+
},
327+
{
328+
// If the created date is an exact match, we need a tiebreaker,
329+
// so we use the _id field from the cursor.
330+
createdAt: new Date(nextCreatedDate),
331+
_id: { $lt: nextId }
332+
}
333+
]
334+
}
335+
]
336+
}
337+
}
338+
} else {
339+
filters = { $match: matchClause }
340+
}
341+
return filters
342+
}
343+
344+
private getMatchClause (entityUuid: MUUID, entityType: string): any {
345+
if (entityType === 'user') {
346+
return { userUuid: entityUuid }
347+
} else if (entityType === 'area') {
348+
return { 'entityTags.ancestors': { $regex: entityUuid.toUUID().toString() } }
349+
} else if (entityType === 'climb') {
350+
return { 'entityTags.targetId': entityUuid }
351+
}
352+
throw new Error(`Unexpected entity type: ${entityType}`)
353+
}
354+
355+
private async aggregateMedia (filters: any, first: number): Promise<MediaObject[]> {
356+
return await this.mediaObjectModel.aggregate<MediaObject>([
357+
filters,
358+
{ $sort: { createdAt: -1, _id: -1 } },
359+
{ $limit: first + 1 } // fetch 1 extra to see if there's a next page
360+
])
361+
}
362+
363+
private getMediaConnection (filteredMedia: MediaObject[], itemCount: number, hasNextPage: boolean): any {
364+
return {
365+
edges: filteredMedia.map(node => (
366+
{
367+
node,
368+
cursor: `${node.createdAt.getTime()}_${node._id.toString()}`
369+
}
370+
)),
371+
pageInfo: {
372+
hasNextPage,
373+
totalItems: itemCount,
374+
endCursor: null
375+
}
376+
}
377+
}
307378
}

0 commit comments

Comments
 (0)