diff --git a/examples/production-app/Database.ts b/examples/production-app/Database.ts index 7d34b69f..230eec32 100644 --- a/examples/production-app/Database.ts +++ b/examples/production-app/Database.ts @@ -1,5 +1,8 @@ import { PubSub } from "./PubSub"; import { VC } from "./ViewerContext"; +import { Like } from "./models/Like"; +import { Post } from "./models/Post"; +import { User } from "./models/User"; /** * This module is intended to represent a database. @@ -92,101 +95,115 @@ export async function createPost( title: string; content: string; }, -): Promise { +): Promise { vc.log(`DB query: createPost: ${JSON.stringify(draft)}`); const id = (MOCK_POSTS.length + 1).toString(); const row = { id, ...draft, publishedAt: new Date() }; MOCK_POSTS.push(row); - return row; + return new Post(vc, row); } export async function selectPostsWhereAuthor( vc: VC, authorId: string, -): Promise> { +): Promise> { vc.log(`DB query: selectPostsWhereAuthor: ${authorId}`); - return MOCK_POSTS.filter((post) => post.authorId === authorId); + return MOCK_POSTS.filter((post) => post.authorId === authorId).map((row) => { + return new Post(vc, row); + }); } export async function getPostsByIds( vc: VC, ids: readonly string[], -): Promise> { +): Promise> { vc.log(`DB query: getPostsByIds: ${ids.join(", ")}`); - return ids.map((id) => nullThrows(MOCK_POSTS.find((post) => post.id === id))); + return ids.map((id) => { + const row = nullThrows(MOCK_POSTS.find((post) => post.id === id)); + return new Post(vc, row); + }); } -export async function selectUsers(vc: VC): Promise> { +export async function selectUsers(vc: VC): Promise> { vc.log("DB query: selectUsers"); - return MOCK_USERS; + return MOCK_USERS.map((row) => new User(vc, row)); } export async function createUser( vc: VC, draft: { name: string }, -): Promise { +): Promise { vc.log(`DB query: createUser: ${JSON.stringify(draft)}`); const id = (MOCK_POSTS.length + 1).toString(); const row = { id, ...draft }; MOCK_USERS.push(row); - return row; + return new User(vc, row); } export async function getUsersByIds( vc: VC, ids: readonly string[], -): Promise> { +): Promise> { vc.log(`DB query: getUsersByIds: ${ids.join(", ")}`); - return ids.map((id) => nullThrows(MOCK_USERS.find((user) => user.id === id))); + return ids.map((id) => { + const row = nullThrows(MOCK_USERS.find((user) => user.id === id)); + return new User(vc, row); + }); } -export async function selectLikes(vc: VC): Promise> { +export async function selectLikes(vc: VC): Promise> { vc.log("DB query: selectLikes"); - return MOCK_LIKES; + return MOCK_LIKES.map((row) => new Like(vc, row)); } export async function createLike( vc: VC, like: { userId: string; postId: string }, -): Promise { +): Promise { vc.log(`DB query: createLike: ${JSON.stringify(like)}`); const id = (MOCK_LIKES.length + 1).toString(); const row = { ...like, id, createdAt: new Date() }; MOCK_LIKES.push(row); PubSub.publish("postLiked", like.postId); - return row; + return new Like(vc, row); } export async function getLikesByIds( vc: VC, ids: readonly string[], -): Promise> { +): Promise> { vc.log(`DB query: getLikesByIds: ${ids.join(", ")}`); - return ids.map((id) => nullThrows(MOCK_LIKES.find((like) => like.id === id))); + return ids.map((id) => { + const row = nullThrows(MOCK_LIKES.find((like) => like.id === id)); + return new Like(vc, row); + }); } export async function getLikesByUserId( vc: VC, userId: string, -): Promise> { +): Promise> { vc.log(`DB query: getLikesByUserId: ${userId}`); - return MOCK_LIKES.filter((like) => like.userId === userId); + return MOCK_LIKES.filter((like) => like.userId === userId).map((row) => { + return new Like(vc, row); + }); } export async function getLikesByPostId( vc: VC, postId: string, -): Promise> { +): Promise> { vc.log(`DB query: getLikesByPostId: ${postId}`); - return MOCK_LIKES.filter((like) => like.postId === postId); + return MOCK_LIKES.filter((like) => like.postId === postId).map((row) => { + return new Like(vc, row); + }); } -export async function getLikesForPost( - vc: VC, - postId: string, -): Promise { +export async function getLikesForPost(vc: VC, postId: string): Promise { vc.log(`DB query: getLikesForPost: ${postId}`); - return MOCK_LIKES.filter((like) => like.postId === postId); + return MOCK_LIKES.filter((like) => like.postId === postId).map((row) => { + return new Like(vc, row); + }); } function nullThrows(value: T | null | undefined): T { diff --git a/examples/production-app/README.md b/examples/production-app/README.md index b7a1c53f..88ce2334 100644 --- a/examples/production-app/README.md +++ b/examples/production-app/README.md @@ -15,7 +15,7 @@ This example includes a relatively fully featured app to demonstrate how real-wo Dataloaders are attached to the per-request viewer context. This enables per-request caching while avoiding the risk of leaking data between requests/users. -The viewer context is passed all the way through the app to the data layer. This would enable permission checking to be defined as close to the data as possible. +The viewer context (VC) is passed all the way through the app to the data layer. This would enable permission checking to be defined as close to the data as possible. Additionally, the VC is stashed on each model instance, enabling the model create edges to other models without needing to get a new VC. ## Running the demo diff --git a/examples/production-app/ViewerContext.ts b/examples/production-app/ViewerContext.ts index 7c6548eb..acb59afc 100644 --- a/examples/production-app/ViewerContext.ts +++ b/examples/production-app/ViewerContext.ts @@ -1,13 +1,9 @@ import DataLoader from "dataloader"; -import { - LikeRow, - PostRow, - UserRow, - getPostsByIds, - getUsersByIds, - getLikesByIds, -} from "./Database"; +import { getPostsByIds, getUsersByIds, getLikesByIds } from "./Database"; import { YogaInitialContext } from "graphql-yoga"; +import { Post } from "./models/Post"; +import { User } from "./models/User"; +import { Like } from "./models/Like"; /** * Viewer Context @@ -20,22 +16,22 @@ import { YogaInitialContext } from "graphql-yoga"; * through the entire request. */ export class VC { - _postLoader: DataLoader; - _userLoader: DataLoader; - _likeLoader: DataLoader; + _postLoader: DataLoader; + _userLoader: DataLoader; + _likeLoader: DataLoader; _logs: string[] = []; constructor() { this._postLoader = new DataLoader((ids) => getPostsByIds(this, ids)); this._userLoader = new DataLoader((ids) => getUsersByIds(this, ids)); this._likeLoader = new DataLoader((ids) => getLikesByIds(this, ids)); } - async getPostById(id: string): Promise { + async getPostById(id: string): Promise { return this._postLoader.load(id); } - async getUserById(id: string): Promise { + async getUserById(id: string): Promise { return this._userLoader.load(id); } - async getLikeById(id: string): Promise { + async getLikeById(id: string): Promise { return this._likeLoader.load(id); } userId(): string { diff --git a/examples/production-app/graphql/Node.ts b/examples/production-app/graphql/Node.ts index e6ecbe65..62ca6287 100644 --- a/examples/production-app/graphql/Node.ts +++ b/examples/production-app/graphql/Node.ts @@ -2,9 +2,6 @@ import { fromGlobalId, toGlobalId } from "graphql-relay"; import { ID } from "grats"; import { Query } from "./Roots"; import { Ctx } from "../ViewerContext"; -import { User } from "../models/User"; -import { Post } from "../models/Post"; -import { Like } from "../models/Like"; /** * Converts a globally unique ID into a local ID asserting @@ -53,11 +50,11 @@ export async function node( // source of bugs. switch (type) { case "User": - return new User(await ctx.vc.getUserById(id)); + return ctx.vc.getUserById(id); case "Post": - return new Post(await ctx.vc.getPostById(id)); + return ctx.vc.getPostById(id); case "Like": - return new Like(await ctx.vc.getLikeById(id)); + return ctx.vc.getLikeById(id); default: throw new Error(`Unknown typename: ${type}`); } diff --git a/examples/production-app/models/Like.ts b/examples/production-app/models/Like.ts index 100a197c..fa6952a7 100644 --- a/examples/production-app/models/Like.ts +++ b/examples/production-app/models/Like.ts @@ -24,15 +24,15 @@ export class Like extends Model implements GraphQLNode { /** * The user who liked the post. * @gqlField */ - async liker(_args: unknown, ctx: Ctx): Promise { - return new User(await ctx.vc.getUserById(this.row.userId)); + async liker(): Promise { + return this.vc.getUserById(this.row.userId); } /** * The post that was liked. * @gqlField */ - async post(_args: unknown, ctx: Ctx): Promise { - return new Post(await ctx.vc.getPostById(this.row.postId)); + async post(): Promise { + return this.vc.getPostById(this.row.postId); } } @@ -59,5 +59,5 @@ export async function createLike( ): Promise { const id = getLocalTypeAssert(args.input.postId, "Post"); await DB.createLike(ctx.vc, { ...args.input, userId: ctx.vc.userId() }); - return { post: new Post(await ctx.vc.getPostById(id)) }; + return { post: await ctx.vc.getPostById(id) }; } diff --git a/examples/production-app/models/LikeConnection.ts b/examples/production-app/models/LikeConnection.ts index 660fb780..ec867bdd 100644 --- a/examples/production-app/models/LikeConnection.ts +++ b/examples/production-app/models/LikeConnection.ts @@ -5,7 +5,6 @@ import { Query, Subscription } from "../graphql/Roots"; import { Like } from "./Like"; import { PageInfo } from "../graphql/Connection"; import { connectionFromArray } from "graphql-relay"; -import { Post } from "./Post"; import { PubSub } from "../PubSub"; import { filter, map, pipe } from "graphql-yoga"; import { getLocalTypeAssert } from "../graphql/Node"; @@ -53,8 +52,7 @@ export async function likes( }, ctx: Ctx, ): Promise { - const rows = await DB.selectLikes(ctx.vc); - const likes = rows.map((row) => new Like(row)); + const likes = await DB.selectLikes(ctx.vc); return { ...connectionFromArray(likes, args), count: likes.length, @@ -71,11 +69,10 @@ export async function postLikes( ctx: Ctx, ): Promise> { const id = getLocalTypeAssert(args.postID, "Post"); - const postRow = await ctx.vc.getPostById(id); - const post = new Post(postRow); + const post = await ctx.vc.getPostById(id); return pipe( PubSub.subscribe("postLiked"), filter((postId) => postId === id), - map(() => post.likes({}, ctx)), + map(() => post.likes({})), ); } diff --git a/examples/production-app/models/Model.ts b/examples/production-app/models/Model.ts index 473b8cc3..2e3f13ec 100644 --- a/examples/production-app/models/Model.ts +++ b/examples/production-app/models/Model.ts @@ -1,8 +1,10 @@ +import { VC } from "../ViewerContext"; + /** * Generic model class built around a database row */ export abstract class Model { - constructor(protected row: R) {} + constructor(protected vc: VC, protected row: R) {} localID(): string { return this.row.id; } diff --git a/examples/production-app/models/Post.ts b/examples/production-app/models/Post.ts index 9e47856b..66054a4d 100644 --- a/examples/production-app/models/Post.ts +++ b/examples/production-app/models/Post.ts @@ -6,7 +6,6 @@ import { Model } from "./Model"; import { Mutation } from "../graphql/Roots"; import { ID, Int } from "../../../dist/src"; import { GqlDate } from "../graphql/CustomScalars"; -import { Like } from "./Like"; import { LikeConnection } from "./LikeConnection"; import { connectionFromArray } from "graphql-relay"; @@ -40,25 +39,21 @@ export class Post extends Model implements GraphQLNode { /** * The author of the post. This cannot change after the post is created. * @gqlField */ - async author(_args: unknown, ctx: Ctx): Promise { - return new User(await ctx.vc.getUserById(this.row.authorId)); + async author(): Promise { + return this.vc.getUserById(this.row.authorId); } /** * All the likes this post has received. * **Note:** You can use this connection to access the number of likes. * @gqlField */ - async likes( - args: { - first?: Int | null; - after?: string | null; - last?: Int | null; - before?: string | null; - }, - ctx: Ctx, - ): Promise { - const rows = await DB.getLikesForPost(ctx.vc, this.row.id); - const likes = rows.map((row) => new Like(row)); + async likes(args: { + first?: Int | null; + after?: string | null; + last?: Int | null; + before?: string | null; + }): Promise { + const likes = await DB.getLikesForPost(this.vc, this.row.id); return { ...connectionFromArray(likes, args), count: likes.length, @@ -89,9 +84,9 @@ export async function createPost( args: { input: CreatePostInput }, ctx: Ctx, ): Promise { - const row = await DB.createPost(ctx.vc, { + const post = await DB.createPost(ctx.vc, { ...args.input, authorId: getLocalTypeAssert(args.input.authorId, "User"), }); - return { post: new Post(row) }; + return { post }; } diff --git a/examples/production-app/models/PostConnection.ts b/examples/production-app/models/PostConnection.ts index d629dcc8..811b43ae 100644 --- a/examples/production-app/models/PostConnection.ts +++ b/examples/production-app/models/PostConnection.ts @@ -29,6 +29,6 @@ export async function posts( ctx: Ctx, ): Promise> { const rows = await DB.selectPosts(ctx.vc); - const posts = rows.map((row) => new Post(row)); + const posts = rows.map((row) => new Post(ctx.vc, row)); return connectionFromArray(posts, args); } diff --git a/examples/production-app/models/User.ts b/examples/production-app/models/User.ts index 1ae15fca..3b9d27d4 100644 --- a/examples/production-app/models/User.ts +++ b/examples/production-app/models/User.ts @@ -21,9 +21,8 @@ export class User extends Model implements GraphQLNode { /** * All posts written by this user. Note that there is no guarantee of order. * @gqlField */ - async posts(_: unknown, ctx: Ctx): Promise> { - const rows = await DB.selectPostsWhereAuthor(ctx.vc, this.row.id); - const posts = rows.map((row) => new Post(row)); + async posts(): Promise> { + const posts = await DB.selectPostsWhereAuthor(this.vc, this.row.id); return connectionFromArray(posts, {}); } } @@ -49,6 +48,6 @@ export async function createUser( args: { input: CreateUserInput }, ctx: Ctx, ): Promise { - const row = await DB.createUser(ctx.vc, args.input); - return { user: new User(row) }; + const user = await DB.createUser(ctx.vc, args.input); + return { user }; } diff --git a/examples/production-app/models/UserConnection.ts b/examples/production-app/models/UserConnection.ts index 1cd759fb..b291162e 100644 --- a/examples/production-app/models/UserConnection.ts +++ b/examples/production-app/models/UserConnection.ts @@ -28,7 +28,6 @@ export async function users( }, ctx: Ctx, ): Promise> { - const rows = await DB.selectUsers(ctx.vc); - const users = rows.map((row) => new User(row)); + const users = await DB.selectUsers(ctx.vc); return connectionFromArray(users, args); } diff --git a/examples/production-app/models/Viewer.ts b/examples/production-app/models/Viewer.ts index 497ce6f9..a3bc34ba 100644 --- a/examples/production-app/models/Viewer.ts +++ b/examples/production-app/models/Viewer.ts @@ -12,7 +12,7 @@ export class Viewer { * The currently authenticated user. * @gqlField */ async user(__: unknown, ctx: Ctx): Promise { - return new User(await ctx.vc.getUserById(ctx.vc.userId())); + return ctx.vc.getUserById(ctx.vc.userId()); } /** @@ -27,7 +27,7 @@ export class Viewer { for (const row of rows) { // Simulate a slow algorithm await new Promise((resolve) => setTimeout(resolve, 500)); - yield new Post(row); + yield new Post(ctx.vc, row); } } // --- Root Fields ---