From ebcd4c9328445e39d88aa7dec7a175c8ef418a56 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 12:34:51 +0100 Subject: [PATCH 01/16] Convert from CJS to ESM --- .../{businessLogic.js => businessLogic.mjs} | 13 +++---- .../{database.js => database.mjs} | 6 +-- .../examples/users-and-friends/dataloaders.js | 13 ------- .../users-and-friends/dataloaders.mjs | 12 ++++++ .../users-and-friends/{index.js => index.mjs} | 39 ++++++++++--------- .../users-and-friends/{plans.js => plans.mjs} | 16 ++++---- 6 files changed, 47 insertions(+), 52 deletions(-) rename grafast/website/examples/users-and-friends/{businessLogic.js => businessLogic.mjs} (82%) rename grafast/website/examples/users-and-friends/{database.js => database.mjs} (94%) delete mode 100644 grafast/website/examples/users-and-friends/dataloaders.js create mode 100644 grafast/website/examples/users-and-friends/dataloaders.mjs rename grafast/website/examples/users-and-friends/{index.js => index.mjs} (93%) rename grafast/website/examples/users-and-friends/{plans.js => plans.mjs} (51%) diff --git a/grafast/website/examples/users-and-friends/businessLogic.js b/grafast/website/examples/users-and-friends/businessLogic.mjs similarity index 82% rename from grafast/website/examples/users-and-friends/businessLogic.js rename to grafast/website/examples/users-and-friends/businessLogic.mjs index 35a1d51735..80d45d45cb 100644 --- a/grafast/website/examples/users-and-friends/businessLogic.js +++ b/grafast/website/examples/users-and-friends/businessLogic.mjs @@ -2,7 +2,7 @@ * Business logic is the same for Grafast and GraphQL */ -const { db } = require("./database"); +import { db } from "./database.mjs"; const logSql = process.env.LOG_SQL === "1"; @@ -16,7 +16,7 @@ const queryAll = (query, parameters) => ); }); -exports.getUsersByIds = async function getUsersByIds(ids, options = {}) { +export async function getUsersByIds(ids, options = {}) { const columns = options.columns ? [...new Set(["id", ...options.columns])] : ["*"]; @@ -27,12 +27,9 @@ exports.getUsersByIds = async function getUsersByIds(ids, options = {}) { ids, ); return ids.map((id) => users.find((u) => u.id === id)); -}; +} -exports.getFriendshipsByUserIds = async function getFriendshipsByUserIds( - userIds, - options = {}, -) { +export async function getFriendshipsByUserIds(userIds, options = {}) { const columns = options.columns ? [...new Set(["user_id", ...options.columns])] : ["*"]; @@ -45,4 +42,4 @@ exports.getFriendshipsByUserIds = async function getFriendshipsByUserIds( return userIds.map((userId) => friendships.filter((f) => f.user_id === userId), ); -}; +} diff --git a/grafast/website/examples/users-and-friends/database.js b/grafast/website/examples/users-and-friends/database.mjs similarity index 94% rename from grafast/website/examples/users-and-friends/database.js rename to grafast/website/examples/users-and-friends/database.mjs index 274da38c82..53ab5ecd93 100644 --- a/grafast/website/examples/users-and-friends/database.js +++ b/grafast/website/examples/users-and-friends/database.mjs @@ -1,5 +1,5 @@ -const sqlite3 = require("sqlite3"); -const db = new sqlite3.Database(":memory:"); +import sqlite3 from "sqlite3"; +export const db = new sqlite3.Database(":memory:"); const NAMES = [ "Alice", "Bob", @@ -77,5 +77,3 @@ CREATE TABLE friendships ( }); // db.close(); - -exports.db = db; diff --git a/grafast/website/examples/users-and-friends/dataloaders.js b/grafast/website/examples/users-and-friends/dataloaders.js deleted file mode 100644 index 73ff5ec915..0000000000 --- a/grafast/website/examples/users-and-friends/dataloaders.js +++ /dev/null @@ -1,13 +0,0 @@ -const { - getUsersByIds, - getFriendshipsByUserIds, -} = require("./businessLogic.js"); - -const DataLoader = require("dataloader"); - -exports.makeDataLoaders = () => ({ - userLoader: new DataLoader((ids) => getUsersByIds(ids)), - friendshipsByUserIdLoader: new DataLoader((userIds) => - getFriendshipsByUserIds(userIds), - ), -}); diff --git a/grafast/website/examples/users-and-friends/dataloaders.mjs b/grafast/website/examples/users-and-friends/dataloaders.mjs new file mode 100644 index 0000000000..7e20da3942 --- /dev/null +++ b/grafast/website/examples/users-and-friends/dataloaders.mjs @@ -0,0 +1,12 @@ +import DataLoader from "dataloader"; + +import { getFriendshipsByUserIds, getUsersByIds } from "./businessLogic.mjs"; + +export function makeDataLoaders() { + return { + userLoader: new DataLoader((ids) => getUsersByIds(ids)), + friendshipsByUserIdLoader: new DataLoader((userIds) => + getFriendshipsByUserIds(userIds), + ), + }; +} diff --git a/grafast/website/examples/users-and-friends/index.js b/grafast/website/examples/users-and-friends/index.mjs similarity index 93% rename from grafast/website/examples/users-and-friends/index.js rename to grafast/website/examples/users-and-friends/index.mjs index 4db65fa303..424c6f916d 100644 --- a/grafast/website/examples/users-and-friends/index.js +++ b/grafast/website/examples/users-and-friends/index.mjs @@ -1,24 +1,25 @@ -const { - buildSchema, - // printSchema, - graphql, - execute: graphqlExecute, - parse, - validate, -} = require("graphql"); -const { - makeGrafastSchema, +import { writeFile } from "node:fs/promises"; + +import { context, each, + execute as grafastExecute, grafast, + makeGrafastSchema, stringifyPayload, - execute: grafastExecute, -} = require("grafast"); -const { planToMermaid } = require("grafast/mermaid"); -const { makeDataLoaders } = require("./dataloaders"); -const { userById, friendshipsByUserId } = require("./plans"); -const fsp = require("node:fs/promises"); -const { resolvePresets } = require("graphile-config"); +} from "grafast"; +import { planToMermaid } from "grafast/mermaid"; +import { resolvePresets } from "graphile-config"; +import { + buildSchema, + execute as graphqlExecute, + graphql, + parse, + validate, +} from "graphql"; + +import { makeDataLoaders } from "./dataloaders.mjs"; +import { friendshipsByUserId, userById } from "./plans.mjs"; // Benchmark settings const NUMBER_OF_REQUESTS = 10000; @@ -284,14 +285,14 @@ async function main() { resolvedPreset: { grafast: { explain: ["plan"] } }, }); - await fsp.writeFile( + await writeFile( `${__dirname}/plan.mermaid`, planToMermaid( grafastResultWithPlan.extensions.explain.operations[0].plan, { skipBuckets: false, concise: true }, ), ); - await fsp.writeFile( + await writeFile( `${__dirname}/plan-simplified.mermaid`, planToMermaid( grafastResultWithPlan.extensions.explain.operations[0].plan, diff --git a/grafast/website/examples/users-and-friends/plans.js b/grafast/website/examples/users-and-friends/plans.mjs similarity index 51% rename from grafast/website/examples/users-and-friends/plans.js rename to grafast/website/examples/users-and-friends/plans.mjs index 987e762f60..9723373262 100644 --- a/grafast/website/examples/users-and-friends/plans.js +++ b/grafast/website/examples/users-and-friends/plans.mjs @@ -1,17 +1,17 @@ -const { - getUsersByIds, - getFriendshipsByUserIds, -} = require("./businessLogic.js"); +import { loadMany, loadOne } from "grafast"; -const { loadOne, loadMany } = require("grafast"); +import { getFriendshipsByUserIds, getUsersByIds } from "./businessLogic.mjs"; const userByIdCallback = (ids, { attributes }) => getUsersByIds(ids, { columns: attributes }); userByIdCallback.displayName = "userById"; -exports.userById = ($id) => loadOne($id, "id", userByIdCallback); +export function userById($id) { + return loadOne($id, "id", userByIdCallback); +} const friendshipsByUserIdCallback = (ids, { attributes }) => getFriendshipsByUserIds(ids, { columns: attributes }); friendshipsByUserIdCallback.displayName = "friendshipsByUserId"; -exports.friendshipsByUserId = ($id) => - loadMany($id, "user_id", friendshipsByUserIdCallback); +export function friendshipsByUserId($id) { + return loadMany($id, "user_id", friendshipsByUserIdCallback); +} From 8ec23e1df1dd3e16a64256bd840bef1d1312f89c Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 13:38:16 +0100 Subject: [PATCH 02/16] Allow applying 'if' to inhibitOnNull --- grafast/grafast/src/steps/__flag.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/grafast/grafast/src/steps/__flag.ts b/grafast/grafast/src/steps/__flag.ts index 3f7e5de020..6d2c3c9d8d 100644 --- a/grafast/grafast/src/steps/__flag.ts +++ b/grafast/grafast/src/steps/__flag.ts @@ -283,8 +283,12 @@ export class __FlagStep extends ExecutableStep { * Example use case: get user by id, but id is null: no need to fetch the user * since we know they won't exist. */ -export function inhibitOnNull($step: ExecutableStep) { +export function inhibitOnNull( + $step: ExecutableStep, + options?: { if?: FlagStepOptions["if"] }, +) { return new __FlagStep($step, { + ...options, acceptFlags: DEFAULT_ACCEPT_FLAGS & ~FLAG_NULL, }); } From b7cfeffd1019d61c713a5054c4f5929960a2a6ab Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 13:38:51 +0100 Subject: [PATCH 03/16] docs(changeset): Allow applying `if` to `inhibitOnNull` - e.g. only inhibit on null if some other condition matches. --- .changeset/tough-carrots-breathe.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tough-carrots-breathe.md diff --git a/.changeset/tough-carrots-breathe.md b/.changeset/tough-carrots-breathe.md new file mode 100644 index 0000000000..45ca8ad70e --- /dev/null +++ b/.changeset/tough-carrots-breathe.md @@ -0,0 +1,6 @@ +--- +"grafast": patch +--- + +Allow applying `if` to `inhibitOnNull` - e.g. only inhibit on null if some other +condition matches. From bd38d5e9172d8d5c9f62a10e95636f7e2423db5b Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 13:52:51 +0100 Subject: [PATCH 04/16] Add nodeIdFromNode helper --- grafast/grafast/src/index.ts | 3 +++ grafast/grafast/src/steps/index.ts | 8 ++++++- grafast/grafast/src/steps/node.ts | 8 +++++++ .../step-library/standard-steps/node.md | 23 ++++++++++++++++++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/grafast/grafast/src/index.ts b/grafast/grafast/src/index.ts index bd26fe0d69..f407e1b95e 100644 --- a/grafast/grafast/src/index.ts +++ b/grafast/grafast/src/index.ts @@ -187,6 +187,7 @@ import { LoadStep, makeDecodeNodeId, node, + nodeIdFromNode, NodeStep, object, ObjectPlanMeta, @@ -401,6 +402,7 @@ export { newObjectTypeBuilder, node, NodeIdCodec, + nodeIdFromNode, NodeIdHandler, NodeStep, noop, @@ -525,6 +527,7 @@ exportAsMany("grafast", { first, node, specFromNodeId, + nodeIdFromNode, polymorphicBranch, PolymorphicBranchStep, makeDecodeNodeId, diff --git a/grafast/grafast/src/steps/index.ts b/grafast/grafast/src/steps/index.ts index 2463537a36..c4cde6036b 100644 --- a/grafast/grafast/src/steps/index.ts +++ b/grafast/grafast/src/steps/index.ts @@ -76,7 +76,13 @@ export { ListTransformOptions, ListTransformReduce, } from "./listTransform.js"; -export { makeDecodeNodeId, node, NodeStep, specFromNodeId } from "./node.js"; +export { + makeDecodeNodeId, + node, + nodeIdFromNode, + NodeStep, + specFromNodeId, +} from "./node.js"; export { object, ObjectPlanMeta, ObjectStep } from "./object.js"; export { partitionByIndex } from "./partitionByIndex.js"; export { diff --git a/grafast/grafast/src/steps/node.ts b/grafast/grafast/src/steps/node.ts index 3fef483e49..f81f2118c8 100644 --- a/grafast/grafast/src/steps/node.ts +++ b/grafast/grafast/src/steps/node.ts @@ -123,6 +123,14 @@ export function specFromNodeId( return handler.getSpec($decoded); } +export function nodeIdFromNode( + handler: NodeIdHandler, + $node: ExecutableStep, +) { + const specifier = handler.plan($node); + return lambda(specifier, handler.codec.encode); +} + export function makeDecodeNodeId(handlers: NodeIdHandler[]) { const codecs = [...new Set(handlers.map((h) => h.codec))]; diff --git a/grafast/website/grafast/step-library/standard-steps/node.md b/grafast/website/grafast/step-library/standard-steps/node.md index a2491c55dc..06eec764c3 100644 --- a/grafast/website/grafast/step-library/standard-steps/node.md +++ b/grafast/website/grafast/step-library/standard-steps/node.md @@ -145,7 +145,7 @@ function specFromNodeId( Here's an example of an `updateUser` mutation that uses the `userHandler` example handler from above: -```js +```ts const typeDefs = /* GraphQL */ ` extend type Mutation { updateUser(id: ID!, patch: UserPatch!): UpdateUserPayload @@ -173,3 +173,24 @@ const plans = { }, }; ``` + +## nodeIdFromNode + +Given you have a step representing a node and you know the handler for it, this +helper method will return a step representing the Node ID for this node. + +```ts +const typeDefs = /* GraphQL */ ` + extend type User { + id: ID! + } +`; + +const planResolvers = { + User: { + id($user) { + return nodeIdFromNode(handlers.User, $user); + }, + }, +}; +``` From 3c161f7e13375105b1035a7d5d1c0f2b507ca5c7 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 13:53:09 +0100 Subject: [PATCH 05/16] docs(changeset): Add `nodeIdFromNode()` helper --- .changeset/curvy-nails-walk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curvy-nails-walk.md diff --git a/.changeset/curvy-nails-walk.md b/.changeset/curvy-nails-walk.md new file mode 100644 index 0000000000..75fb0fe3a1 --- /dev/null +++ b/.changeset/curvy-nails-walk.md @@ -0,0 +1,5 @@ +--- +"grafast": patch +--- + +Add `nodeIdFromNode()` helper From faefaf6b3eb11c8cfda481323d6e38941f19b29a Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 13:54:31 +0100 Subject: [PATCH 06/16] Expand users-and-friends example to demonstrate inhibit/trap usage --- .../users-and-friends/businessLogic.mjs | 40 ++++ .../examples/users-and-friends/database.mjs | 30 +++ .../users-and-friends/dataloaders.mjs | 9 +- .../examples/users-and-friends/index.mjs | 177 ++++++++++++++++-- .../examples/users-and-friends/nodeIds.mjs | 57 ++++++ .../examples/users-and-friends/plans.mjs | 27 ++- 6 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 grafast/website/examples/users-and-friends/nodeIds.mjs diff --git a/grafast/website/examples/users-and-friends/businessLogic.mjs b/grafast/website/examples/users-and-friends/businessLogic.mjs index 80d45d45cb..55606403f4 100644 --- a/grafast/website/examples/users-and-friends/businessLogic.mjs +++ b/grafast/website/examples/users-and-friends/businessLogic.mjs @@ -29,6 +29,19 @@ export async function getUsersByIds(ids, options = {}) { return ids.map((id) => users.find((u) => u.id === id)); } +export async function getPostsByIds(ids, options = {}) { + const columns = options.columns + ? [...new Set(["id", ...options.columns])] + : ["*"]; + const posts = await queryAll( + `select ${columns.join(", ")} from posts where id in (${ids + .map(() => `?`) + .join(", ")})`, + ids, + ); + return ids.map((id) => posts.find((u) => u.id === id)); +} + export async function getFriendshipsByUserIds(userIds, options = {}) { const columns = options.columns ? [...new Set(["user_id", ...options.columns])] @@ -43,3 +56,30 @@ export async function getFriendshipsByUserIds(userIds, options = {}) { friendships.filter((f) => f.user_id === userId), ); } + +export async function getPostsByAuthorIds(rawUserIds, options = {}) { + const userIds = rawUserIds.filter((uid) => uid != null && uid >= 0); + const columns = options.columns + ? [...new Set(["author_id", ...options.columns])] + : ["*"]; + const posts = + userIds.length > 0 + ? await queryAll( + `select ${columns.join(", ")} from posts where author_id in (${userIds + .map(() => `?`) + .join(", ")})`, + userIds, + ) + : []; + const anonymousPosts = + userIds.length !== rawUserIds.length + ? await queryAll( + `select ${columns.join(", ")} from posts where author_id is null`, + ) + : []; + return rawUserIds.map((userId) => + userId == null || userId < 0 + ? anonymousPosts + : posts.filter((f) => f.author_id === userId), + ); +} diff --git a/grafast/website/examples/users-and-friends/database.mjs b/grafast/website/examples/users-and-friends/database.mjs index 53ab5ecd93..bcd72ed221 100644 --- a/grafast/website/examples/users-and-friends/database.mjs +++ b/grafast/website/examples/users-and-friends/database.mjs @@ -40,6 +40,17 @@ CREATE TABLE friendships ( created_at int not null default CURRENT_TIMESTAMP, updated_at int ); +`, + ); + db.run( + `\ +CREATE TABLE posts ( + id int primary key, + author_id int, + content text, + created_at int not null default CURRENT_TIMESTAMP, + updated_at int +); `, ); @@ -74,6 +85,25 @@ CREATE TABLE friendships ( } stmt.finalize(); } + + { + const stmt = db.prepare( + "INSERT INTO posts (id, author_id, content) VALUES (?, ?, ?)", + ); + let id = 0; + for (let nameIndex = 0; nameIndex < NAMES.length; nameIndex++) { + // A stable list of friendIds + const friendIndexes = NAMES.map((_, idx) => idx).filter( + (idx) => idx % (nameIndex + 1) === 0, + ); + for (const friendIndex of friendIndexes) { + stmt.run(++id, nameIndex + 1, `I'm friends with ${NAMES[friendIndex]}`); + } + } + stmt.run(++id, null, `Have you heard about updog?`); + stmt.run(++id, null, `Sillypeoplesaywhat.`); + stmt.finalize(); + } }); // db.close(); diff --git a/grafast/website/examples/users-and-friends/dataloaders.mjs b/grafast/website/examples/users-and-friends/dataloaders.mjs index 7e20da3942..3d93a9061e 100644 --- a/grafast/website/examples/users-and-friends/dataloaders.mjs +++ b/grafast/website/examples/users-and-friends/dataloaders.mjs @@ -1,6 +1,10 @@ import DataLoader from "dataloader"; -import { getFriendshipsByUserIds, getUsersByIds } from "./businessLogic.mjs"; +import { + getFriendshipsByUserIds, + getPostsByAuthorIds, + getUsersByIds, +} from "./businessLogic.mjs"; export function makeDataLoaders() { return { @@ -8,5 +12,8 @@ export function makeDataLoaders() { friendshipsByUserIdLoader: new DataLoader((userIds) => getFriendshipsByUserIds(userIds), ), + postsByAuthorIdLoader: new DataLoader((authorIds) => + getPostsByAuthorIds(authorIds), + ), }; } diff --git a/grafast/website/examples/users-and-friends/index.mjs b/grafast/website/examples/users-and-friends/index.mjs index 424c6f916d..a707292708 100644 --- a/grafast/website/examples/users-and-friends/index.mjs +++ b/grafast/website/examples/users-and-friends/index.mjs @@ -1,12 +1,19 @@ +import assert from "node:assert"; import { writeFile } from "node:fs/promises"; import { + condition, context, each, execute as grafastExecute, grafast, + inhibitOnNull, makeGrafastSchema, + nodeIdFromNode, + specFromNodeId, stringifyPayload, + trap, + TRAP_INHIBITED, } from "grafast"; import { planToMermaid } from "grafast/mermaid"; import { resolvePresets } from "graphile-config"; @@ -19,7 +26,11 @@ import { } from "graphql"; import { makeDataLoaders } from "./dataloaders.mjs"; -import { friendshipsByUserId, userById } from "./plans.mjs"; +import { base64JSONCodec, handlers } from "./nodeIds.mjs"; +import { friendshipsByUserId, postsByAuthorId, userById } from "./plans.mjs"; + +// ESM port of Node.js __dirname +const __dirname = new URL(".", import.meta.url).pathname; // Benchmark settings const NUMBER_OF_REQUESTS = 10000; @@ -30,19 +41,55 @@ const asString = true; const typeDefs = /* GraphQL */ ` type Query { currentUser: User + # Note: null 'id' is valid: that would be anonymous posts. + postsByAuthorId(id: ID): [Post] + } + interface Node { + id: ID! } - type User { + type User implements Node { + id: ID! name: String! friends: [User]! } + type Post implements Node { + id: ID! + author: User + content: String + } `; const resolvers = { Query: { async currentUser(_, args, context) { return context.userLoader.load(context.currentUserId); }, + postsByAuthorId(_, args, context) { + const nodeId = args.id; + if (nodeId == null) { + return context.postsByAuthorIdLoader.load( + // DataLoader doesn't support null here, so we're using -1 as a + // stand-in + -1, + ); + } + const tuple = base64JSONCodec.decode(nodeId); + if ( + Array.isArray(tuple) && + tuple.length === 2 && + tuple[0] === "User" && + typeof tuple[1] === "number" + ) { + return context.postsByAuthorIdLoader.load(tuple[1]); + } else { + // Only Users can author posts, posts by any other entity is an empty array + return []; + } + }, }, User: { + id(user) { + return base64JSONCodec.encode(["User", user.id]); + }, name(user) { return user.full_name; }, @@ -56,6 +103,14 @@ const resolvers = { return friends; }, }, + Post: { + id(post) { + return base64JSONCodec.encode(["Post", post.id]); + }, + author(post) { + return post.author_id ? context.userLoader.load(post.author_id) : null; + }, + }, }; const planResolvers = { @@ -63,8 +118,25 @@ const planResolvers = { currentUser() { return userById(context().get("currentUserId")); }, + postsByAuthorId(_, { $id }) { + const spec = specFromNodeId(handlers.User, $id); + // This will be null if the ID is null or invalid + const $userIdOrNull = spec.id; + // Inhibit the ID if the spec returns null but the $id was non-null + const $validUserIdOrNull = inhibitOnNull($userIdOrNull, { + if: condition("not null", $id), + }); + // Fetch the posts (if not inhibited) + const $posts = postsByAuthorId($validUserIdOrNull); + return trap($posts, TRAP_INHIBITED, { + valueForInhibited: "EMPTY_LIST", + }); + }, }, User: { + id($user) { + return nodeIdFromNode(handlers.User, $user); + }, name($user) { return $user.get("full_name"); }, @@ -76,6 +148,14 @@ const planResolvers = { return $friends; }, }, + Post: { + id($post) { + return nodeIdFromNode(handlers.Post, $post); + }, + author($post) { + return userById($post.get("author_id")); + }, + }, }; const makeGraphQLSchema = () => { @@ -143,28 +223,24 @@ async function runGraphQL() { const baseContext = { currentUserId: 1 }; const resolvedPreset = resolvePresets([{}]); async function runGrafastWithGraphQLSchema() { - const result = await grafastExecute( - { - schema: schemaDL, - document, - contextValue: { ...baseContext, ...makeDataLoaders() }, - }, + const result = await grafastExecute({ + schema: schemaDL, + document, + contextValue: { ...baseContext, ...makeDataLoaders() }, resolvedPreset, - asString, - ); + outputDataAsString: asString, + }); return stringifyPayload(result, asString); } async function runGrafast() { - const result = await grafastExecute( - { - schema: schemaGF, - document, - contextValue: baseContext, - }, + const result = await grafastExecute({ + schema: schemaGF, + document, + contextValue: baseContext, resolvedPreset, - asString, - ); + outputDataAsString: asString, + }); return stringifyPayload(result, asString); } @@ -263,6 +339,71 @@ async function runCompare() { async function main() { switch (process.argv[2]) { + case "nodeId": { + const source = /* GraphQL */ ` + query PostsByAuthorId($id: ID) { + postsByAuthorId(id: $id) { + content + #id + #author { + # id + # name + # friends { + # name + # } + #} + } + } + `; + + // To make it fair, we parse and validate the query ahead of time and just time + // execution (because grafast caches parse results). + + const document = parse(source); + const errors1 = validate(schemaDL, document); + const errors2 = validate(schemaGF, document); + if (errors1.length) { + throw errors1[0]; + } + if (errors2.length) { + throw errors2[0]; + } + const vars = [ + // User 1 + { id: base64JSONCodec.encode(["User", 1]) }, + // Anonymous posts + { id: null }, + // Invalid Node ID for this query, expect empty array + { id: base64JSONCodec.encode(["Post", 2]) }, + ]; + for (const variableValues of vars) { + const result1 = JSON.stringify( + await graphqlExecute({ + schema: schemaDL, + document, + variableValues, + contextValue: { + ...baseContext, + ...makeDataLoaders(), + }, + }), + ); + const result2 = stringifyPayload( + await grafastExecute({ + schema: schemaGF, + document, + variableValues, + contextValue: { ...baseContext }, + resolvedPreset, + outputDataAsString: asString, + }), + asString, + ); + assert.equal(result2, result1); + console.log(result1); + } + break; + } case "docs": { console.log("Generating query plans for documentation"); diff --git a/grafast/website/examples/users-and-friends/nodeIds.mjs b/grafast/website/examples/users-and-friends/nodeIds.mjs new file mode 100644 index 0000000000..fdcd02e219 --- /dev/null +++ b/grafast/website/examples/users-and-friends/nodeIds.mjs @@ -0,0 +1,57 @@ +import { access, constant, ExecutableStep, list } from "grafast"; + +import { postById, userById } from "./plans.mjs"; + +/** + * Converts values into base64-encoded JSON and back. + * + * @type {import('grafast').NodeIdCodec} + */ +const base64JSONCodec = { + name: "base64JSON", + encode(value) { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64"); + }, + decode(value) { + return JSON.parse(Buffer.from(value, "base64").toString("utf8")); + }, +}; + +// Grafast optimizations: +base64JSONCodec.encode.isSyncAndSafe = true; +base64JSONCodec.decode.isSyncAndSafe = true; + +const _base64JSONCodec = base64JSONCodec; +export { _base64JSONCodec as base64JSONCodec }; + +/** + * Creates a Grafast handler for NodeIDs. + * + * @param {string} typeName + * @param {(spec: any) => ExecutableStep} get + * @returns {import('grafast').NodeIdHandler} + */ +function makeHandler(typeName, codec, get) { + return { + typeName, + codec, + plan($data) { + return list([constant(typeName), $data.get("id")]); + }, + match(list) { + return list[0] === typeName; + }, + getSpec($list) { + return { id: access($list, 1) }; + }, + get, + }; +} + +const handlers = { + User: makeHandler("User", base64JSONCodec, (spec) => userById(spec.id)), + Post: makeHandler("Post", base64JSONCodec, (spec) => postById(spec.id)), +}; + +const _handlers = handlers; +export { _handlers as handlers }; diff --git a/grafast/website/examples/users-and-friends/plans.mjs b/grafast/website/examples/users-and-friends/plans.mjs index 9723373262..8c0f545032 100644 --- a/grafast/website/examples/users-and-friends/plans.mjs +++ b/grafast/website/examples/users-and-friends/plans.mjs @@ -1,6 +1,11 @@ import { loadMany, loadOne } from "grafast"; -import { getFriendshipsByUserIds, getUsersByIds } from "./businessLogic.mjs"; +import { + getFriendshipsByUserIds, + getPostsByAuthorIds, + getPostsByIds, + getUsersByIds, +} from "./businessLogic.mjs"; const userByIdCallback = (ids, { attributes }) => getUsersByIds(ids, { columns: attributes }); @@ -9,9 +14,23 @@ export function userById($id) { return loadOne($id, "id", userByIdCallback); } -const friendshipsByUserIdCallback = (ids, { attributes }) => +const friendshipsByUserIdsCallback = (ids, { attributes }) => getFriendshipsByUserIds(ids, { columns: attributes }); -friendshipsByUserIdCallback.displayName = "friendshipsByUserId"; +friendshipsByUserIdsCallback.displayName = "friendshipsByUserId"; export function friendshipsByUserId($id) { - return loadMany($id, "user_id", friendshipsByUserIdCallback); + return loadMany($id, "user_id", friendshipsByUserIdsCallback); +} + +const postByIdCallback = (ids, { attributes }) => + getPostsByIds(ids, { columns: attributes }); +postByIdCallback.displayName = "postById"; +export function postById($id) { + return loadOne($id, "id", postByIdCallback); +} + +const postsByAuthorIdsCallback = (ids, { attributes }) => + getPostsByAuthorIds(ids, { columns: attributes }); +postsByAuthorIdsCallback.displayName = "postsByAuthorId"; +export function postsByAuthorId($id) { + return loadMany($id, "user_id", postsByAuthorIdsCallback); } From 882a96f44f9acd83094a556cab94081b94041cc4 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 13:54:56 +0100 Subject: [PATCH 07/16] Update diagrams in website --- .../examples/users-and-friends/plan-simplified.mermaid | 4 +++- grafast/website/examples/users-and-friends/plan.mermaid | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/grafast/website/examples/users-and-friends/plan-simplified.mermaid b/grafast/website/examples/users-and-friends/plan-simplified.mermaid index 612447f8bc..f5c3dc8781 100644 --- a/grafast/website/examples/users-and-friends/plan-simplified.mermaid +++ b/grafast/website/examples/users-and-friends/plan-simplified.mermaid @@ -34,4 +34,6 @@ flowchart TD classDef bucket3 stroke:#ffa500 class Bucket3,__Item15,Access17,Load18 bucket3 classDef bucket4 stroke:#0000ff - class Bucket4 bucket4 \ No newline at end of file + class Bucket4 bucket4 + classDef unary fill:#fafffa,borderWidth:8px + class Access7,Load8,Load11,__Value0,__Value3,__Value5 unary \ No newline at end of file diff --git a/grafast/website/examples/users-and-friends/plan.mermaid b/grafast/website/examples/users-and-friends/plan.mermaid index 29719cf10d..08c3e8a043 100644 --- a/grafast/website/examples/users-and-friends/plan.mermaid +++ b/grafast/website/examples/users-and-friends/plan.mermaid @@ -41,4 +41,6 @@ flowchart TD class Bucket4 bucket4 Bucket0 --> Bucket1 Bucket1 --> Bucket3 - Bucket3 --> Bucket4 \ No newline at end of file + Bucket3 --> Bucket4 + classDef unary fill:#fafffa,borderWidth:8px + class Access7,Load8,Load11,__Value0,__Value3,__Value5 unary \ No newline at end of file From 82ea7d1e48713ae09f2d0b30dcd61c42f94590eb Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 14:56:27 +0100 Subject: [PATCH 08/16] Drop extensions --- .../examples/users-and-friends/index.mjs | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/grafast/website/examples/users-and-friends/index.mjs b/grafast/website/examples/users-and-friends/index.mjs index a707292708..3abaf60d0b 100644 --- a/grafast/website/examples/users-and-friends/index.mjs +++ b/grafast/website/examples/users-and-friends/index.mjs @@ -388,19 +388,32 @@ async function main() { }, }), ); - const result2 = stringifyPayload( - await grafastExecute({ - schema: schemaGF, - document, - variableValues, - contextValue: { ...baseContext }, - resolvedPreset, - outputDataAsString: asString, - }), - asString, - ); + const grafastRawResult = await grafastExecute({ + schema: schemaGF, + document, + variableValues, + contextValue: { ...baseContext }, + resolvedPreset: { + extends: [resolvedPreset], + grafast: { + explain: ["plan"], + }, + }, + outputDataAsString: asString, + }); + //const extensions = grafastRawResult.extensions; + delete grafastRawResult.extensions; + + const result2 = stringifyPayload(grafastRawResult, asString); assert.equal(result2, result1); console.log(result1); + // await writeFile( + // `${__dirname}/planforjem.mermaid`, + // planToMermaid(extensions.explain.operations[0].plan, { + // skipBuckets: false, + // concise: true, + // }), + // ); } break; } From a1a2b1fa831d972b98160fa41baa0dacc3eb66fd Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 15:34:19 +0100 Subject: [PATCH 09/16] Fix flags on listItem bucket root step --- grafast/grafast/src/engine/LayerPlan.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/grafast/grafast/src/engine/LayerPlan.ts b/grafast/grafast/src/engine/LayerPlan.ts index f9b1238122..19dba6f29c 100644 --- a/grafast/grafast/src/engine/LayerPlan.ts +++ b/grafast/grafast/src/engine/LayerPlan.ts @@ -5,12 +5,13 @@ import te from "tamedevil"; import * as assert from "../assert.js"; import type { Bucket } from "../bucket.js"; import { inspect } from "../inspect.js"; -import type { UnaryExecutionValue } from "../interfaces.js"; +import type { ExecutionValue, UnaryExecutionValue } from "../interfaces.js"; import { FLAG_ERROR, FLAG_INHIBITED, FLAG_NULL, FORBIDDEN_BY_NULLABLE_BOUNDARY_FLAGS, + NO_FLAGS, } from "../interfaces.js"; import { resolveType } from "../polymorphic.js"; import type { @@ -519,17 +520,15 @@ export class LayerPlan { } const itemStepId = this.rootStep.id; // Item steps are **NOT** unary - const itemStepIdList: any[] | null = this.rootStep._isUnary ? null : []; + let ev: ExecutionValue; if (this.rootStep._isUnary) { // handled later const list = listStepStore.at(0); - store.set( - itemStepId, - unaryExecutionValue(Array.isArray(list) ? list[0] : list), - ); + ev = unaryExecutionValue(Array.isArray(list) ? list[0] : list); } else { - store.set(itemStepId, batchExecutionValue(itemStepIdList!)); + ev = batchExecutionValue([]); } + store.set(itemStepId, ev); for (const stepId of copyStepIds) { const ev = parentBucket.store.get(stepId)!; @@ -556,8 +555,10 @@ export class LayerPlan { for (let j = 0, l = list.length; j < l; j++) { const newIndex = size++; newIndexes.push(newIndex); - if (itemStepIdList !== null) { - itemStepIdList[newIndex] = list[j]; + if (ev.isBatch) { + (ev.entries[newIndex] as any[]) = list[j]; + // TODO: are these the right flags? + ev._flags[newIndex] = list[j] == null ? FLAG_NULL : NO_FLAGS; } polymorphicPathList[newIndex] = From c4b735ea9d6cc61a593725bae578da9be72c1597 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 15:34:47 +0100 Subject: [PATCH 10/16] More direct check --- grafast/grafast/src/engine/OutputPlan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafast/grafast/src/engine/OutputPlan.ts b/grafast/grafast/src/engine/OutputPlan.ts index 7453598e4f..0ecb538ffd 100644 --- a/grafast/grafast/src/engine/OutputPlan.ts +++ b/grafast/grafast/src/engine/OutputPlan.ts @@ -731,7 +731,7 @@ export function getChildBucketAndIndex( "GrafastInternalError<83d0e3cc-7eec-4185-85b4-846540288162>: arrayIndex must be supplied iff outputPlan is an array", ); } - if (outputPlan && childOutputPlan.layerPlan === bucket.layerPlan) { + if (outputPlan != null && childOutputPlan.layerPlan === bucket.layerPlan) { // Same layer; straightforward return [bucket, bucketIndex]; } From f3d142688ed537974cdfc69c12280736afb8d1e9 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 16:03:36 +0100 Subject: [PATCH 11/16] Use process.nextTick in Load step to improve latency at the cost of throughput --- grafast/grafast/src/steps/load.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grafast/grafast/src/steps/load.ts b/grafast/grafast/src/steps/load.ts index 9238888812..599406c827 100644 --- a/grafast/grafast/src/steps/load.ts +++ b/grafast/grafast/src/steps/load.ts @@ -353,14 +353,14 @@ export class LoadStep< meta.loadBatchesByLoad.set(this.load, loadBatches); // Guaranteed by the metaKey to be equivalent for all entries sharing the same `meta`. Note equivalent is not identical; key order may change. const loadOptions = this.loadOptions!; - setTimeout(() => { + process.nextTick(() => { // Don't allow adding anything else to the batch meta.loadBatchesByLoad!.delete(this.load); executeBatches(loadBatches!, this.load, { ...loadOptions, unary: unary as TUnarySpec, }); - }, 0); + }); } return (async () => { const loadResults = await deferred; From a674a9923bc908c9315afa40e0cb256ee0953d16 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 16:08:36 +0100 Subject: [PATCH 12/16] docs(changeset): Fix performance issue in `loadOne()`/`loadMany()` due to using `setTimeout(cb, 0)`, now using `process.nextTick(cb)`. High enough concurrency and the issue goes away, but with limited concurrency this causes a lot of `(idle)` in profiling and thus completing 10k items took longer. (Lots of time spent in `epoll_pwait`.) --- .changeset/lemon-spoons-dress.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/lemon-spoons-dress.md diff --git a/.changeset/lemon-spoons-dress.md b/.changeset/lemon-spoons-dress.md new file mode 100644 index 0000000000..c9cf1a2545 --- /dev/null +++ b/.changeset/lemon-spoons-dress.md @@ -0,0 +1,9 @@ +--- +"grafast": patch +--- + +Fix performance issue in `loadOne()`/`loadMany()` due to using +`setTimeout(cb, 0)`, now using `process.nextTick(cb)`. High enough concurrency +and the issue goes away, but with limited concurrency this causes a lot of +`(idle)` in profiling and thus completing 10k items took longer. (Lots of time +spent in `epoll_pwait`.) From 992ac871f8bb8789d08ade4339fb0748b6a0c064 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 16:26:51 +0100 Subject: [PATCH 13/16] Fix reference to users-and-friends for website test --- grafast/website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafast/website/package.json b/grafast/website/package.json index 54e634a793..9e217ffce0 100644 --- a/grafast/website/package.json +++ b/grafast/website/package.json @@ -13,7 +13,7 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "posttest": "yarn build", - "test": "cd examples/users-and-friends && node index.js" + "test": "cd examples/users-and-friends && node index.mjs" }, "dependencies": { "@docusaurus/core": "2.4.1", From f126ca635d990dea59c961ed546095231d2a49b4 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 16:37:21 +0100 Subject: [PATCH 14/16] We know it will always be a batch, leverage that for performance --- grafast/grafast/src/engine/LayerPlan.ts | 17 ++++++----------- grafast/grafast/src/engine/executeBucket.ts | 4 ++-- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/grafast/grafast/src/engine/LayerPlan.ts b/grafast/grafast/src/engine/LayerPlan.ts index 19dba6f29c..f6075c3313 100644 --- a/grafast/grafast/src/engine/LayerPlan.ts +++ b/grafast/grafast/src/engine/LayerPlan.ts @@ -520,17 +520,14 @@ export class LayerPlan { } const itemStepId = this.rootStep.id; // Item steps are **NOT** unary - let ev: ExecutionValue; if (this.rootStep._isUnary) { - // handled later - const list = listStepStore.at(0); - ev = unaryExecutionValue(Array.isArray(list) ? list[0] : list); - } else { - ev = batchExecutionValue([]); + throw new Error("listItem layer plan can't have a unary root step!"); } + const ev = batchExecutionValue([]); store.set(itemStepId, ev); for (const stepId of copyStepIds) { + // Deliberate shadowing const ev = parentBucket.store.get(stepId)!; if (ev.isBatch) { // Prepare store with an empty list for each copyPlanId @@ -555,11 +552,9 @@ export class LayerPlan { for (let j = 0, l = list.length; j < l; j++) { const newIndex = size++; newIndexes.push(newIndex); - if (ev.isBatch) { - (ev.entries[newIndex] as any[]) = list[j]; - // TODO: are these the right flags? - ev._flags[newIndex] = list[j] == null ? FLAG_NULL : NO_FLAGS; - } + (ev.entries[newIndex] as any[]) = list[j]; + // TODO: are these the right flags? + ev._flags[newIndex] = list[j] == null ? FLAG_NULL : NO_FLAGS; polymorphicPathList[newIndex] = parentBucket.polymorphicPathList[originalIndex]; diff --git a/grafast/grafast/src/engine/executeBucket.ts b/grafast/grafast/src/engine/executeBucket.ts index dba187a41e..f5f43a2d7d 100644 --- a/grafast/grafast/src/engine/executeBucket.ts +++ b/grafast/grafast/src/engine/executeBucket.ts @@ -1276,7 +1276,7 @@ export function bucketToString(this: Bucket) { export function batchExecutionValue( entries: TData[], _flags: ExecutionEntryFlags[] = arrayOfLength(entries.length, 0), -): ExecutionValue { +): BatchExecutionValue { let cachedStateUnion: ExecutionEntryFlags | null = null; return { at: batchEntriesAt, @@ -1334,7 +1334,7 @@ function _copyResult( export function unaryExecutionValue( value: TData, _entryFlags: ExecutionEntryFlags = 0, -): ExecutionValue { +): UnaryExecutionValue { return { at: unaryAt, isBatch: false, From 0b172a10e721462ee6e3d8e7727cf2a83c828b61 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 16:40:44 +0100 Subject: [PATCH 15/16] Tweak type --- grafast/grafast/src/engine/LayerPlan.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grafast/grafast/src/engine/LayerPlan.ts b/grafast/grafast/src/engine/LayerPlan.ts index f6075c3313..0714861cf7 100644 --- a/grafast/grafast/src/engine/LayerPlan.ts +++ b/grafast/grafast/src/engine/LayerPlan.ts @@ -523,7 +523,7 @@ export class LayerPlan { if (this.rootStep._isUnary) { throw new Error("listItem layer plan can't have a unary root step!"); } - const ev = batchExecutionValue([]); + const ev = batchExecutionValue([] as any[]); store.set(itemStepId, ev); for (const stepId of copyStepIds) { @@ -552,7 +552,7 @@ export class LayerPlan { for (let j = 0, l = list.length; j < l; j++) { const newIndex = size++; newIndexes.push(newIndex); - (ev.entries[newIndex] as any[]) = list[j]; + (ev.entries as any[])[newIndex] = list[j]; // TODO: are these the right flags? ev._flags[newIndex] = list[j] == null ? FLAG_NULL : NO_FLAGS; From 2d993d9fd35a9364ee0d27fbfc33a6fa9904152c Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Tue, 14 May 2024 16:44:44 +0100 Subject: [PATCH 16/16] Lint fixes --- grafast/grafast/src/engine/LayerPlan.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/grafast/grafast/src/engine/LayerPlan.ts b/grafast/grafast/src/engine/LayerPlan.ts index 0714861cf7..941fc18323 100644 --- a/grafast/grafast/src/engine/LayerPlan.ts +++ b/grafast/grafast/src/engine/LayerPlan.ts @@ -5,7 +5,7 @@ import te from "tamedevil"; import * as assert from "../assert.js"; import type { Bucket } from "../bucket.js"; import { inspect } from "../inspect.js"; -import type { ExecutionValue, UnaryExecutionValue } from "../interfaces.js"; +import type { UnaryExecutionValue } from "../interfaces.js"; import { FLAG_ERROR, FLAG_INHIBITED, @@ -19,11 +19,7 @@ import type { ModifierStep, UnbatchedExecutableStep, } from "../step"; -import { - batchExecutionValue, - newBucket, - unaryExecutionValue, -} from "./executeBucket.js"; +import { batchExecutionValue, newBucket } from "./executeBucket.js"; import type { OperationPlan } from "./OperationPlan"; /*