Skip to content

Commit

Permalink
Stash VC on models and push model creation to the DB layer
Browse files Browse the repository at this point in the history
  • Loading branch information
captbaritone committed Mar 29, 2024
1 parent 3265f90 commit 30d84d5
Show file tree
Hide file tree
Showing 12 changed files with 88 additions and 86 deletions.
71 changes: 44 additions & 27 deletions 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.
Expand Down Expand Up @@ -92,101 +95,115 @@ export async function createPost(
title: string;
content: string;
},
): Promise<PostRow> {
): Promise<Post> {
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<Array<PostRow>> {
): Promise<Array<Post>> {
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<Array<PostRow>> {
): Promise<Array<Post>> {
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<Array<UserRow>> {
export async function selectUsers(vc: VC): Promise<Array<User>> {
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<UserRow> {
): Promise<User> {
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<Array<UserRow>> {
): Promise<Array<User>> {
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<Array<LikeRow>> {
export async function selectLikes(vc: VC): Promise<Array<Like>> {
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<LikeRow> {
): Promise<Like> {
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<Array<LikeRow>> {
): Promise<Array<Like>> {
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<Array<LikeRow>> {
): Promise<Array<Like>> {
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<Array<LikeRow>> {
): Promise<Array<Like>> {
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<LikeRow[]> {
export async function getLikesForPost(vc: VC, postId: string): Promise<Like[]> {
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<T>(value: T | null | undefined): T {
Expand Down
2 changes: 1 addition & 1 deletion examples/production-app/README.md
Expand Up @@ -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

Expand Down
24 changes: 10 additions & 14 deletions 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
Expand All @@ -20,22 +16,22 @@ import { YogaInitialContext } from "graphql-yoga";
* through the entire request.
*/
export class VC {
_postLoader: DataLoader<string, PostRow>;
_userLoader: DataLoader<string, UserRow>;
_likeLoader: DataLoader<string, LikeRow>;
_postLoader: DataLoader<string, Post>;
_userLoader: DataLoader<string, User>;
_likeLoader: DataLoader<string, Like>;
_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<PostRow> {
async getPostById(id: string): Promise<Post> {
return this._postLoader.load(id);
}
async getUserById(id: string): Promise<UserRow> {
async getUserById(id: string): Promise<User> {
return this._userLoader.load(id);
}
async getLikeById(id: string): Promise<LikeRow> {
async getLikeById(id: string): Promise<Like> {
return this._likeLoader.load(id);
}
userId(): string {
Expand Down
9 changes: 3 additions & 6 deletions examples/production-app/graphql/Node.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
}
Expand Down
10 changes: 5 additions & 5 deletions examples/production-app/models/Like.ts
Expand Up @@ -24,15 +24,15 @@ export class Like extends Model<DB.LikeRow> implements GraphQLNode {
/**
* The user who liked the post.
* @gqlField */
async liker(_args: unknown, ctx: Ctx): Promise<User> {
return new User(await ctx.vc.getUserById(this.row.userId));
async liker(): Promise<User> {
return this.vc.getUserById(this.row.userId);
}

/**
* The post that was liked.
* @gqlField */
async post(_args: unknown, ctx: Ctx): Promise<Post> {
return new Post(await ctx.vc.getPostById(this.row.postId));
async post(): Promise<Post> {
return this.vc.getPostById(this.row.postId);
}
}

Expand All @@ -59,5 +59,5 @@ export async function createLike(
): Promise<CreateLikePayload> {
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) };
}
9 changes: 3 additions & 6 deletions examples/production-app/models/LikeConnection.ts
Expand Up @@ -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";
Expand Down Expand Up @@ -53,8 +52,7 @@ export async function likes(
},
ctx: Ctx,
): Promise<LikeConnection> {
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,
Expand All @@ -71,11 +69,10 @@ export async function postLikes(
ctx: Ctx,
): Promise<AsyncIterable<LikeConnection>> {
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({})),
);
}
4 changes: 3 additions & 1 deletion 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<R extends { id: string }> {
constructor(protected row: R) {}
constructor(protected vc: VC, protected row: R) {}
localID(): string {
return this.row.id;
}
Expand Down
27 changes: 11 additions & 16 deletions examples/production-app/models/Post.ts
Expand Up @@ -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";

Expand Down Expand Up @@ -40,25 +39,21 @@ export class Post extends Model<DB.PostRow> implements GraphQLNode {
/**
* The author of the post. This cannot change after the post is created.
* @gqlField */
async author(_args: unknown, ctx: Ctx): Promise<User> {
return new User(await ctx.vc.getUserById(this.row.authorId));
async author(): Promise<User> {
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<LikeConnection> {
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<LikeConnection> {
const likes = await DB.getLikesForPost(this.vc, this.row.id);
return {
...connectionFromArray(likes, args),
count: likes.length,
Expand Down Expand Up @@ -89,9 +84,9 @@ export async function createPost(
args: { input: CreatePostInput },
ctx: Ctx,
): Promise<CreatePostPayload> {
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 };
}
2 changes: 1 addition & 1 deletion examples/production-app/models/PostConnection.ts
Expand Up @@ -29,6 +29,6 @@ export async function posts(
ctx: Ctx,
): Promise<Connection<Post>> {
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);
}
9 changes: 4 additions & 5 deletions examples/production-app/models/User.ts
Expand Up @@ -21,9 +21,8 @@ export class User extends Model<DB.UserRow> implements GraphQLNode {
/**
* All posts written by this user. Note that there is no guarantee of order.
* @gqlField */
async posts(_: unknown, ctx: Ctx): Promise<Connection<Post>> {
const rows = await DB.selectPostsWhereAuthor(ctx.vc, this.row.id);
const posts = rows.map((row) => new Post(row));
async posts(): Promise<Connection<Post>> {
const posts = await DB.selectPostsWhereAuthor(this.vc, this.row.id);
return connectionFromArray(posts, {});
}
}
Expand All @@ -49,6 +48,6 @@ export async function createUser(
args: { input: CreateUserInput },
ctx: Ctx,
): Promise<CreateUserPayload> {
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 };
}
3 changes: 1 addition & 2 deletions examples/production-app/models/UserConnection.ts
Expand Up @@ -28,7 +28,6 @@ export async function users(
},
ctx: Ctx,
): Promise<Connection<User>> {
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);
}

0 comments on commit 30d84d5

Please sign in to comment.