Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(metagen): client_ts #790

Merged
merged 15 commits into from
Aug 25, 2024
Merged

feat(metagen): client_ts #790

merged 15 commits into from
Aug 25, 2024

Conversation

Yohe-Am
Copy link
Contributor

@Yohe-Am Yohe-Am commented Jul 11, 2024

Migration notes

...

  • The change comes with new or modified tests
  • Hard-to-understand functions have explanatory comments
  • End-user documentation is updated to reflect the change

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling in the typegraph function to provide better error messages.
  • Chores

    • Updated Docker image references to use docker.io prefix for consistency.
    • Excluded unnecessary files from the VSCode settings.
    • Enhanced configurability of Docker commands in development tasks.
    • Updated environment variable GHJK_VERSION to reflect a semantic versioning format.
  • New Features

    • Introduced modules and methods for TypeScript and Python code generation in the metagen library, enhancing client generation capabilities.
    • Added test_typegraph_3 function for improved testing capabilities.
    • Included metadata for the package manager in the project settings.

@Yohe-Am Yohe-Am requested a review from zifeo July 11, 2024 13:40
Copy link

linear bot commented Jul 11, 2024

libs/metagen/src/client_ts/static/mod.ts Outdated Show resolved Hide resolved
typegate/tests/metagen/typegraphs/sample/ts/mdk.ts Outdated Show resolved Hide resolved
libs/metagen/src/shared/types.rs Show resolved Hide resolved
@Yohe-Am
Copy link
Contributor Author

Yohe-Am commented Jul 11, 2024

@coderabbitai review

Copy link
Contributor

coderabbitai bot commented Jul 11, 2024

Walkthrough

The changes encompass updates to Docker image references, enhancements for generating TypeScript and Python client code, and improvements in error handling for TypeGraph functions. New constants and configuration adjustments boost configurability and code management across multiple modules. Additionally, several new files have been introduced to support the extended functionalities, including client generation for various programming languages and improved testing capabilities.

Changes

File(s) / Group Change Summary
.github/workflows/release.yml, whiz.yaml Updated GHJK_VERSION from "b702292" to "v0.2.1" indicating a formal release version.
.vscode/settings.json Replaced deno.enablePaths with deno.disablePaths, removed deno.importMap, and reformatted python.analysis.extraPaths.
dev/envs/compose.base.yml Updated Docker image references for envoy and redis to include docker.io prefix for clarity in image sourcing.
dev/tasks-dev.ts Introduced constant DOCKER_CMD to retrieve the Docker command from the environment or default to "docker".
libs/metagen/src/client_ts/mod.rs, libs/metagen/src/client_py/mod.rs Added new modules for TypeScript and Python client generation, including configuration structures and generation methods.
libs/metagen/src/lib.rs Updated to include new modules for client_py, client_rs, and client_ts, enhancing multi-language client code generation capabilities.
libs/metagen/src/tests/fixtures.rs Introduced new async function test_typegraph_3 and modified test_typegraph_1 for updated testing paths.
typegate/tests/metagen/typegraphs/sample.ts, sample/metatype.yml Added new TypeGraph implementation and YAML configuration file for client code generation settings, including a PostgreSQL connection string in secrets.
typegate/tests/metagen/typegraphs/sample/py/client.py Implemented functionality for handling GraphQL queries and mutations in a type-safe manner.
typegate/tests/metagen/typegraphs/sample/rs/Cargo.toml, client.rs, main.rs Introduced a Rust package configuration and implemented a system for handling GraphQL queries and mutations.
typegraph/deno/sdk/src/typegraph.ts Added error handling to the typegraph function to manage potential errors from the builder execution.
website/package.json Introduced a new "packageManager" property to specify the package manager version.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant TypeScriptGenerator
    participant GraphQLTransportCore
    participant GraphQLTransport

    User->>TypeScriptGenerator: Initialize with Config
    TypeScriptGenerator->>GraphQLTransportCore: Create GraphQL Transport
    GraphQLTransportCore-->>GraphQLTransport: Provide Transport Details
    TypeScriptGenerator->>GraphQLTransport: Generate Code and Execute Queries
    GraphQLTransport-->>TypeScriptGenerator: Return Query Results
    TypeScriptGenerator-->>User: Provide Generated Code and Results
Loading
sequenceDiagram
    participant User
    participant TypeGraph
    participant ErrorHandler
    participant Builder

    User->>TypeGraph: Call `typegraph` function
    TypeGraph->>Builder: Execute Builder Function
    Builder-->>TypeGraph: Success/Error
    TypeGraph->>ErrorHandler: Handle Error (if any)
    ErrorHandler-->>TypeGraph: Return Modified Error
    TypeGraph-->>User: Return Result/Error
Loading

Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

Share
Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai generate interesting stats about this repository and render them as a table.
    • @coderabbitai show all the console.log statements in this repository.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (invoked as PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Additionally, you can add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Outside diff range, codebase verification and nitpick comments (6)
libs/metagen/src/client_ts/node_metas.rs (4)

1-3: Add a brief file description.

Consider adding a brief description of the file's purpose at the top.

+ // This module provides TypeScript node metadata rendering utilities.

15-15: Add a detailed method description.

Consider adding a detailed description of what this method does, including the parameters and return values.

+ /// Renders metadata for a TypeScript object node.

47-47: Add a detailed method description.

Consider adding a detailed description of what this method does, including the parameters and return values.

+ /// Renders metadata for a TypeScript function node.

92-92: Add a detailed method description.

Consider adding a detailed description of what this method does, including the parameters and return values.

+ /// Renders the metadata for the provided TypeNode.
libs/metagen/src/shared/types.rs (2)

10-10: Consider adding documentation for VisitedTypePaths.

While the type definition is clear, adding a brief comment explaining its purpose would enhance maintainability.


27-33: Consider adding documentation for VisitCursor.

Adding a brief comment explaining the purpose and usage of VisitCursor would enhance code readability and maintainability.

libs/metagen/src/client_ts/node_metas.rs Show resolved Hide resolved
.ghjk/lock.json Outdated Show resolved Hide resolved
.ghjk/lock.json Outdated Show resolved Hide resolved
typegraph/deno/sdk/src/typegraph.ts Show resolved Hide resolved
libs/metagen/src/shared/types.rs Show resolved Hide resolved
libs/metagen/src/shared/types.rs Show resolved Hide resolved
Copy link

codecov bot commented Jul 11, 2024

Codecov Report

Attention: Patch coverage is 60.00000% with 6 lines in your changes missing coverage. Please review.

Project coverage is 68.87%. Comparing base (2db4e13) to head (75e1e5c).
Report is 1 commits behind head on main.

Files Patch % Lines
typegraph/deno/sdk/src/typegraph.ts 25.00% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #790      +/-   ##
==========================================
- Coverage   68.90%   68.87%   -0.03%     
==========================================
  Files         144      144              
  Lines       16673    16680       +7     
  Branches     1473     1474       +1     
==========================================
+ Hits        11488    11489       +1     
- Misses       5159     5165       +6     
  Partials       26       26              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@Yohe-Am
Copy link
Contributor Author

Yohe-Am commented Aug 1, 2024

Usage sample typescript
import { alias, PreparedArgs, QueryGraph } from "./client.ts";

const api1 = new QueryGraph();

const gqlClient = api1.graphql(
  `http://localhost:${Deno.env.get("TG_PORT")}/sample`,
);

const preparedQ = gqlClient.prepareQuery(() => ({
  user: api1.getUser({
    _: "selectAll",
    posts: alias({
      post1: { id: true, slug: true, title: true },
      post2: { _: "selectAll", id: false },
    }),
  }),
  posts: api1.getPosts({ _: "selectAll" }),

  scalarNoArgs: api1.scalarNoArgs(),
}));

const preparedM = gqlClient.prepareMutation((
  args: PreparedArgs<{
    id: string;
    slug: string;
    title: string;
  }>,
) => ({
  scalarArgs: api1.scalarArgs({
    id: args.get("id"),
    slug: args.get("slug"),
    title: args.get("title"),
  }),
  compositeNoArgs: api1.compositeNoArgs({
    _: "selectAll",
  }),
  compositeArgs: api1.compositeArgs({
    id: args.get("id"),
  }, {
    _: "selectAll",
  }),
}));

const res1 = await preparedQ.perform({});
const res1a = await preparedQ.perform({});

const res2 = await preparedM.perform({
  id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2",
  slug: "s",
  title: "t",
});

const res3 = await gqlClient.query({
  user: api1.getUser({
    _: "selectAll",
    posts: alias({
      post1: { id: true, slug: true, title: true },
      post2: { _: "selectAll", id: false },
    }),
  }),
  posts: api1.getPosts({ _: "selectAll" }),

  scalarNoArgs: api1.scalarNoArgs(),
});

const res4 = await gqlClient.mutation({
  scalarArgs: api1.scalarArgs({
    id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2",
    slug: "",
    title: "",
  }),
  compositeNoArgs: api1.compositeNoArgs({
    _: "selectAll",
  }),
  compositeArgs: api1.compositeArgs({
    id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2",
  }, {
    _: "selectAll",
  }),
});

console.log(JSON.stringify([res1, res1a, res2, res3, res4]));
Generation sample typescript
// This file was @generated by metagen and is intended
// to be generated again on subsequent metagen runs.

function _selectionToNodeSet(
  selection: Selection,
  metas: [string, () => NodeMeta][],
  parentPath: string,
): SelectNode<unknown>[] {
  const out = [] as SelectNode[];
  const selectAll = selection._ == "selectAll";
  // set of the user specified nodes to do sanity
  // check at the end
  const foundNodes = new Set(Object.keys(selection));

  for (
    const [nodeName, metaFn] of metas ?? []
  ) {
    foundNodes.delete(nodeName);

    const nodeSelection = selection[nodeName];
    if (!nodeSelection && !selectAll) {
      // this node was not selected
      continue;
    }

    const { argumentTypes, subNodes } = metaFn();

    const nodeInstances = nodeSelection instanceof Alias
      ? nodeSelection.aliases()
      : { [nodeName]: nodeSelection };

    for (
      const [instanceName, instanceSelection] of Object.entries(nodeInstances)
    ) {
      if (!instanceSelection && !selectAll) {
        continue;
      }
      if (instanceSelection instanceof Alias) {
        throw new Error(
          `nested Alias discovored at ${parentPath}.${instanceName}`,
        );
      }
      const node: SelectNode = { instanceName, nodeName };

      if (argumentTypes) {
        // make sure the arg is of the expected form
        let arg = instanceSelection;
        if (Array.isArray(arg)) {
          arg = arg[0];
        }
        // TODO: consider bringing in Zod (after hoisting impl into common lib)
        if (typeof arg != "object" || arg === null) {
          throw new Error(
            `node at ${parentPath}.${instanceName} is a node ` +
              `that requires arguments object but detected argument ` +
              `is typeof ${typeof arg}`,
          );
        }

        const expectedArguments = new Map(Object.entries(argumentTypes));
        node.args = {};
        for (const [key, value] of Object.entries(arg)) {
          const typeName = expectedArguments.get(key);
          // TODO: consider logging a warning if `_` is detected incase user passes
          // Selection as arg
          if (!typeName) {
            throw new Error(
              `unexpected argument ${key} at ${parentPath}.${instanceName}`,
            );
          }
          expectedArguments.delete(key);
          node.args[key] = { typeName, value };
        }
      }

      if (subNodes) {
        // sanity check selection object
        let subSelections = instanceSelection;
        if (argumentTypes) {
          if (!Array.isArray(subSelections)) {
            throw new Error(
              `node at ${parentPath}.${instanceName} ` +
                `is a composite that takes an argument ` +
                `but selection is typeof ${typeof subSelections}`,
            );
          }
          subSelections = subSelections[1];
        } else if (Array.isArray(subSelections)) {
          throw new Error(
            `node at ${parentPath}.${instanceName} ` +
              `is a composite that takes no arguments ` +
              `but selection is typeof ${typeof subSelections}`,
          );
        }
        if (typeof subSelections != "object") {
          throw new Error(
            `node at ${parentPath}.${nodeName} ` +
              `is a no argument composite but first element of ` +
              `selection is typeof ${typeof nodeSelection}`,
          );
        }

        node.subNodes = _selectionToNodeSet(
          // assume it's a Selection. If it's an argument
          // object, mismatch between the node desc should hopefully
          // catch it
          subSelections as Selection,
          subNodes,
          `${parentPath}.${instanceName}`,
        );
      }

      out.push(node);
    }
  }
  foundNodes.delete("_");
  if (foundNodes.size > 0) {
    throw new Error(
      `unexpected nodes found in selection set at ${parentPath}: ${[
        ...foundNodes,
      ]}`,
    );
  }
  return out;
}

/* Query node types section */

type SelectNode<_Out = unknown> = {
  nodeName: string;
  instanceName: string;
  args?: NodeArgs;
  subNodes?: SelectNode[];
};

export class QueryNode<Out> {
  #inner: SelectNode<Out>;
  constructor(
    inner: SelectNode<Out>,
  ) {
    this.#inner = inner;
  }

  inner() {
    return this.#inner;
  }
}

export class MutationNode<Out> {
  #inner: SelectNode<Out>;
  constructor(
    inner: SelectNode<Out>,
  ) {
    this.#inner = inner;
  }

  inner() {
    return this.#inner;
  }
}

type SelectNodeOut<T> = T extends (QueryNode<infer O> | MutationNode<infer O>)
  ? O
  : never;
type QueryDocOut<T> = T extends
  Record<string, QueryNode<unknown> | MutationNode<unknown>> ? {
    [K in keyof T]: SelectNodeOut<T[K]>;
  }
  : never;

type NodeMeta = {
  subNodes?: [string, () => NodeMeta][];
  argumentTypes?: { [name: string]: string };
};

/* Selection types section */

type SelectionFlags = "selectAll";

type Selection = {
  _?: SelectionFlags;
  [key: string]:
    | SelectionFlags
    | ScalarSelectNoArgs
    | ScalarSelectArgs<Record<string, unknown>>
    | CompositeSelectNoArgs<Selection | undefined>
    | CompositeSelectArgs<Record<string, unknown>, Selection>
    | Selection;
};

type ScalarSelectNoArgs =
  | boolean
  | Alias<true>
  | null
  | undefined;

type ScalarSelectArgs<ArgT extends Record<string, unknown>> =
  | ArgT
  | PlaceholderArgs<ArgT>
  | Alias<ArgT | PlaceholderArgs<ArgT>>
  | false
  | null
  | undefined;

type CompositeSelectNoArgs<SelectionT> =
  | SelectionT
  | Alias<SelectionT>
  | false
  | null
  | undefined;

type CompositeSelectArgs<ArgT extends Record<string, unknown>, SelectionT> =
  | [ArgT | PlaceholderArgs<ArgT>, SelectionT]
  | Alias<[ArgT | PlaceholderArgs<ArgT>, SelectionT]>
  | false
  | undefined
  | null;

/**
 * Request multiple instances of a single node under different
 * aliases. Look at {@link alias} for a functional way of instantiating
 * this class.
 */
export class Alias<T> {
  #aliases: Record<string, T>;
  constructor(
    aliases: Record<string, T>,
  ) {
    this.#aliases = aliases;
  }
  aliases() {
    return this.#aliases;
  }
}

/**
 * Request multiple instances of a single node under different
 * aliases.
 */
export function alias<T>(aliases: Record<string, T>): Alias<T> {
  return new Alias(aliases);
}

/* Argument types section */

type NodeArgValue = {
  typeName: string;
  value: unknown;
};

type NodeArgs = {
  [name: string]: NodeArgValue;
};

/**
 * This object is passed to closures used for preparing requests
 * ahead of time for {@link PreparedRequest}s. It allows one to
 * get {@link PlaceholderValue}s that can be used in place of node
 * arguments. At request time, the {@link PreparedRequest} then
 * takes an object that adheres to `T` that can then be used
 * to replace the placeholders.
 */
export class PreparedArgs<T extends Record<string, unknown>> {
  get(key: OnlyStringKeys<T>): PlaceholderValue<T[typeof key]> {
    return new PlaceholderValue(key);
  }
}

/**
 * Placeholder values for use by {@link PreparedRequest}
 */
export class PlaceholderValue<_T> {
  #key: string;
  constructor(key: string) {
    this.#key = key;
  }

  key() {
    return this.#key;
  }
}

export type PlaceholderArgs<T extends Record<string, unknown>> = {
  [K in keyof T]: PlaceholderValue<T[K]>;
};

/* GraphQL section */

/**
 * Options to be used for requests performed by {@link GraphQLTransport}.
 */
export type GraphQlTransportOptions = Omit<RequestInit, "body"> & {
  /**
   * {@link fetch} implementaiton to use. Defaults to the one found in the environment
   */
  fetch?: typeof fetch;
};

function convertQueryNodeGql(
  node: SelectNode,
  variables: Map<string, NodeArgValue>,
) {
  let out = node.nodeName == node.instanceName
    ? node.nodeName
    : `${node.instanceName}: ${node.nodeName}`;

  const args = node.args;
  if (args) {
    out = `${out} (${
      Object.entries(args)
        .map(([key, val]) => {
          const name = `in${variables.size}`;
          variables.set(name, val);
          return `${key}: $${name}`;
        })
        .join(", ")
    })`;
  }

  const subNodes = node.subNodes;
  if (subNodes) {
    out = `${out} { ${
      subNodes.map((node) => convertQueryNodeGql(node, variables)).join(" ")
    } }`;
  }
  return out;
}

function buildGql(
  typeToGqlTypeMap: Record<string, string>,
  query: Record<string, SelectNode>,
  ty: "query" | "mutation",
  name: string = "",
) {
  const variables = new Map<string, NodeArgValue>();

  const rootNodes = Object
    .entries(query)
    .map(([key, node]) => {
      const fixedNode = { ...node, instanceName: key };
      return convertQueryNodeGql(fixedNode, variables);
    })
    .join("\n  ");

  let argsRow = [...variables.entries()]
    .map(([key, val]) => `$${key}: ${typeToGqlTypeMap[val.typeName]}`)
    .join(", ");
  if (argsRow.length > 0) {
    // graphql doesn't like empty parentheses so we only
    // add them if there are args
    argsRow = `(${argsRow})`;
  }

  const doc = `${ty} ${name}${argsRow} {
  ${rootNodes}
}`;
  return {
    doc,
    variables: Object.fromEntries(
      [...variables.entries()]
        .map(([key, val]) => [key, val.value]),
    ),
  };
}

async function fetchGql(
  addr: URL,
  doc: string,
  variables: Record<string, unknown>,
  options: GraphQlTransportOptions,
) {
  // console.log(doc, variables);
  const fetchImpl = options.fetch ?? fetch;
  const res = await fetchImpl(addr, {
    ...options,
    method: "POST",
    headers: {
      accept: "application/json",
      "content-type": "application/json",
      ...options.headers ?? {},
    },
    body: JSON.stringify({
      query: doc,
      variables,
    }),
  });
  if (!res.ok) {
    const body = await res.text().catch((err) => `error reading body: ${err}`);
    throw new (Error as ErrorPolyfill)(
      `graphql request to ${addr} failed with status ${res.status}: ${body}`,
      {
        cause: {
          response: res,
          body,
        },
      },
    );
  }
  if (res.headers.get("content-type") != "application/json") {
    throw new (Error as ErrorPolyfill)(
      "unexpected content type in response",
      {
        cause: {
          response: res,
          body: await res.text().catch((err) => `error reading body: ${err}`),
        },
      },
    );
  }
  return await res.json() as { data: unknown; errors?: object[] };
}

/**
 * Access the typegraph over it's exposed GraphQL API.
 */
export class GraphQLTransport {
  constructor(
    public address: URL,
    public options: GraphQlTransportOptions,
    private typeToGqlTypeMap: Record<string, string>,
  ) {
  }

  async #request(
    doc: string,
    variables: Record<string, unknown>,
    options?: GraphQlTransportOptions,
  ) {
    const res = await fetchGql(this.address, doc, variables, {
      ...this.options,
      ...options,
    });
    if ("errors" in res) {
      throw new (Error as ErrorPolyfill)("graphql errors on response", {
        cause: res.errors,
      });
    }
    return res.data;
  }

  /**
   * Make a query request to the typegraph.
   */
  async query<Doc extends Record<string, QueryNode<unknown>>>(
    query: Doc,
    { options, name = "" }: {
      options?: GraphQlTransportOptions;
      name?: string;
    } = {},
  ): Promise<QueryDocOut<Doc>> {
    const { variables, doc } = buildGql(
      this.typeToGqlTypeMap,
      Object.fromEntries(
        Object.entries(query).map((
          [key, val],
        ) => [key, (val as QueryNode<unknown>).inner()]),
      ),
      "query",
      name,
    );
    return await this.#request(doc, variables, options) as QueryDocOut<Doc>;
  }

  /**
   * Make a mutation request to the typegraph.
   */
  async mutation<Doc extends Record<string, MutationNode<unknown>>>(
    query: Doc,
    { options, name = "" }: {
      options?: GraphQlTransportOptions;
      name?: string;
    } = {},
  ): Promise<QueryDocOut<Doc>> {
    const { variables, doc } = buildGql(
      this.typeToGqlTypeMap,
      Object.fromEntries(
        Object.entries(query).map((
          [key, val],
        ) => [key, (val as MutationNode<unknown>).inner()]),
      ),
      "mutation",
      name,
    );
    return await this.#request(doc, variables, options) as QueryDocOut<Doc>;
  }

  /**
   * Prepare an ahead of time query {@link PreparedRequest}.
   */
  prepareQuery<
    T extends JsonObject,
    Doc extends Record<string, QueryNode<unknown>>,
  >(
    fun: (args: PreparedArgs<T>) => Doc,
    { name = "" }: { name?: string } = {},
  ): PreparedRequest<T, Doc> {
    return new PreparedRequest(
      this.address,
      this.options,
      this.typeToGqlTypeMap,
      fun,
      "query",
      name,
    );
  }

  /**
   * Prepare an ahead of time mutation {@link PreparedRequest}.
   */
  prepareMutation<
    T extends JsonObject,
    Q extends Record<string, MutationNode<unknown>>,
  >(
    fun: (args: PreparedArgs<T>) => Q,
    { name = "" }: { name?: string } = {},
  ): PreparedRequest<T, Q> {
    return new PreparedRequest(
      this.address,
      this.options,
      this.typeToGqlTypeMap,
      fun,
      "mutation",
      name,
    );
  }
}

/**
 * Prepares the GraphQL string ahead of time and allows re-use
 * avoid the compute and garbage overhead of re-building it for
 * repeat queries.
 */
export class PreparedRequest<
  T extends JsonObject,
  Doc extends Record<string, QueryNode<unknown> | MutationNode<unknown>>,
> {
  public doc: string;
  #mappings: Record<string, unknown>;

  constructor(
    private address: URL,
    private options: GraphQlTransportOptions,
    typeToGqlTypeMap: Record<string, string>,
    fun: (args: PreparedArgs<T>) => Doc,
    ty: "query" | "mutation",
    name: string = "",
  ) {
    const args = new PreparedArgs<T>();
    const dryRunNode = fun(args);
    const { doc, variables } = buildGql(
      typeToGqlTypeMap,
      Object.fromEntries(
        Object.entries(dryRunNode).map((
          [key, val],
        ) => [key, (val as MutationNode<unknown>).inner()]),
      ),
      ty,
      name,
    );
    this.doc = doc;
    this.#mappings = variables;
  }

  resolveVariables(
    args: T,
    mappings: Record<string, unknown>,
  ) {
    const resolvedVariables = {} as Record<string, unknown>;
    for (const [key, val] of Object.entries(mappings)) {
      if (val instanceof PlaceholderValue) {
        resolvedVariables[key] = args[val.key()];
      } else if (typeof val == "object" && val != null) {
        this.resolveVariables(args, val as JsonObject);
      } else {
        resolvedVariables[key] = val;
      }
    }
    return resolvedVariables;
  }

  /**
   * Execute the prepared request.
   */
  async perform(args: T, opts?: GraphQlTransportOptions): Promise<
    {
      [K in keyof Doc]: SelectNodeOut<Doc[K]>;
    }
  > {
    const resolvedVariables = this.resolveVariables(args, this.#mappings);
    // console.log(this.doc, {
    //   resolvedVariables,
    //   mapping: this.#mappings,
    // });
    const res = await fetchGql(
      this.address,
      this.doc,
      resolvedVariables,
      {
        ...this.options,
        ...opts,
      },
    );
    if ("errors" in res) {
      throw new (Error as ErrorPolyfill)("graphql errors on response", {
        cause: res.errors,
      });
    }
    return res.data as QueryDocOut<Doc>;
  }
}

/* Util types section */

type OnlyStringKeys<T extends Record<string, unknown>> = {
  [K in keyof T]: K extends string ? K : never;
}[keyof T];

type JsonLiteral = string | number | boolean | null;
type JsonObject = { [key: string]: Json };
type JsonArray = Json[];
type Json = JsonLiteral | JsonObject | JsonArray;

type ErrorPolyfill = new (msg: string, payload: unknown) => Error;

/* QueryGraph section */

class _QueryGraphBase {
  constructor(private typeNameMapGql: Record<string, string>) {}

  /**
   * Get the {@link GraphQLTransport} for the typegraph.
   */
  graphql(addr: URL | string, options?: GraphQlTransportOptions) {
    return new GraphQLTransport(
      new URL(addr),
      options ?? {},
      this.typeNameMapGql,
    );
  }
}

// -------------------------------------------------- //

const nodeMetas = {
  scalar() {
    return {};
  },

  Post(): NodeMeta {
    return {
      subNodes: [
        ["id", nodeMetas.scalar],
        ["slug", nodeMetas.scalar],
        ["title", nodeMetas.scalar],
      ],
    };
  },

  Func27(): NodeMeta {
    return {
      ...nodeMetas.Post(),
    };
  },

  Func25(): NodeMeta {
    return {
      ...nodeMetas.scalar(),
    };
  },

  Func24(): NodeMeta {
    return {
      ...nodeMetas.Post(),
    };
  },
  User(): NodeMeta {
    return {
      subNodes: [
        ["id", nodeMetas.scalar],
        ["email", nodeMetas.scalar],
        ["posts", nodeMetas.Post],
      ],
    };
  },

  Func23(): NodeMeta {
    return {
      ...nodeMetas.User(),
    };
  },

  Func28(): NodeMeta {
    return {
      ...nodeMetas.Post(),
      argumentTypes: {
        id: "String13",
      },
    };
  },

  Func26(): NodeMeta {
    return {
      ...nodeMetas.scalar(),
      argumentTypes: {
        id: "String4",
        slug: "String1",
        title: "String1",
      },
    };
  },
};
export type StringUuid = string;
export type Post = {
  id: StringUuid;
  slug: string;
  title: string;
};
export type StringEmail = string;
export type Post7 = Array<Post>;
export type User = {
  id: StringUuid;
  email: StringEmail;
  posts: Post7;
};
export type Object21 = {
  id: string;
};

export type PostSelections = {
  _?: SelectionFlags;
  id?: ScalarSelectNoArgs;
  slug?: ScalarSelectNoArgs;
  title?: ScalarSelectNoArgs;
};
export type UserSelections = {
  _?: SelectionFlags;
  id?: ScalarSelectNoArgs;
  email?: ScalarSelectNoArgs;
  posts?: CompositeSelectNoArgs<PostSelections>;
};

export class QueryGraph extends _QueryGraphBase {
  constructor() {
    super({
      "String4": "Any",
      "String1": "Any",
      "String13": "Any",
    });
  }

  getUser(select: UserSelections) {
    const inner = _selectionToNodeSet(
      { "getUser": select },
      [["getUser", nodeMetas.Func23]],
      "$q",
    )[0];
    return new QueryNode(inner) as QueryNode<User>;
  }
  getPosts(select: PostSelections) {
    const inner = _selectionToNodeSet(
      { "getPosts": select },
      [["getPosts", nodeMetas.Func24]],
      "$q",
    )[0];
    return new QueryNode(inner) as QueryNode<Post>;
  }
  scalarNoArgs() {
    const inner = _selectionToNodeSet(
      { "scalarNoArgs": true },
      [["scalarNoArgs", nodeMetas.Func25]],
      "$q",
    )[0];
    return new QueryNode(inner) as QueryNode<string>;
  }
  scalarArgs(args: Post | PlaceholderArgs<Post>) {
    const inner = _selectionToNodeSet(
      { "scalarArgs": args },
      [["scalarArgs", nodeMetas.Func26]],
      "$q",
    )[0];
    return new MutationNode(inner) as MutationNode<string>;
  }
  compositeNoArgs(select: PostSelections) {
    const inner = _selectionToNodeSet(
      { "compositeNoArgs": select },
      [["compositeNoArgs", nodeMetas.Func27]],
      "$q",
    )[0];
    return new MutationNode(inner) as MutationNode<Post>;
  }
  compositeArgs(
    args: Object21 | PlaceholderArgs<Object21>,
    select: PostSelections,
  ) {
    const inner = _selectionToNodeSet(
      { "compositeArgs": [args, select] },
      [["compositeArgs", nodeMetas.Func28]],
      "$q",
    )[0];
    return new MutationNode(inner) as MutationNode<Post>;
  }
}
Usage sample python
from client import (
    QueryGraph,
    PostSelections,
    SelectionFlags,
    UserSelections,
    Alias,
)
import json
import os

qg = QueryGraph()
port = os.getenv("TG_PORT")
gql_client = qg.graphql_sync(f"http://localhost:{port}/sample")

prepared_q = gql_client.prepare_query(
    lambda args: {
        "user": qg.get_user(
            UserSelections(
                _=SelectionFlags(select_all=True),
                posts=Alias(
                    post1=PostSelections(
                        id=True,
                        slug=True,
                        title=True,
                    ),
                    post2=PostSelections(
                        _=SelectionFlags(select_all=True),
                        id=False,
                    ),
                ),
            ),
        ),
        "posts": qg.get_posts({"_": SelectionFlags(select_all=True)}),
        "scalarNoArgs": qg.scalar_no_args(),
    },
)

prepared_m = gql_client.prepare_mutation(
    lambda args: {
        "scalarArgs": qg.scalar_args(
            {
                "id": args.get("id"),
                "slug": args.get("slug"),
                "title": args.get("title"),
            }
        ),
        "compositeNoArgs": qg.composite_no_args({"_": SelectionFlags(select_all=True)}),
        "compositeArgs": qg.composite_args(
            {
                "id": args.get("id"),
            },
            {"_": SelectionFlags(select_all=True)},
        ),
    },
)

res1 = prepared_q.perform({})
res1a = prepared_q.perform({})

res2 = prepared_m.perform(
    {
        "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2",
        "slug": "s",
        "title": "t",
    }
)

res3 = gql_client.query(
    {
        "user": qg.get_user(
            UserSelections(
                _=SelectionFlags(select_all=True),
                posts=Alias(
                    post1=PostSelections(
                        id=True,
                        slug=True,
                        title=True,
                    ),
                    post2=PostSelections(
                        _=SelectionFlags(select_all=True),
                        id=False,
                    ),
                ),
            ),
        ),
        "posts": qg.get_posts({"_": SelectionFlags(select_all=True)}),
        "scalarNoArgs": qg.scalar_no_args(),
    }
)

res4 = gql_client.mutation(
    {
        "scalarArgs": qg.scalar_args(
            {
                "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2",
                "slug": "",
                "title": "",
            }
        ),
        "compositeNoArgs": qg.composite_no_args({"_": SelectionFlags(select_all=True)}),
        "compositeArgs": qg.composite_args(
            {
                "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2",
            },
            {"_": SelectionFlags(select_all=True)},
        ),
    }
)

print(json.dumps([res1, res1a, res2, res3, res4]))
Generation sample python
# This file was @generated by metagen and is intended
# to be generated again on subsequent metagen runs.

import typing
import dataclasses as dc
import json
import urllib.request as request
import urllib.error
import http.client as http_c


def selection_to_nodes(
    selection: "SelectionGeneric",
    metas: typing.Dict[str, typing.Callable[[], "NodeMeta"]],
    parent_path: str,
) -> typing.List["SelectNode[typing.Any]"]:
    out = []
    flags = selection.get("_")
    if flags is not None and not isinstance(flags, SelectionFlags):
        raise Exception(
            f"selection field '_' should be of type SelectionFlags but found {type(flags)}"
        )
    select_all = True if flags is not None and flags.select_all else False
    found_nodes = set(selection.keys())
    for node_name, meta_fn in metas.items():
        found_nodes.discard(node_name)

        node_selection = selection.get(node_name)
        if node_selection is False or (node_selection is None and not select_all):
            # this node was not selected
            continue

        meta = meta_fn()

        # we splat out any aliasing of nodes here
        node_instances = (
            [(key, val) for key, val in node_selection.items.items()]
            if isinstance(node_selection, Alias)
            else [(node_name, node_selection)]
        )

        for instance_name, instance_selection in node_instances:
            # print(parent_path, instance_selection, meta.sub_nodes, instance_selection, flags)
            if instance_selection is False or (
                instance_selection is None and not select_all
            ):
                # this instance was not selected
                continue
            if isinstance(instance_selection, Alias):
                raise Exception(
                    f"nested Alias node discovored at {parent_path}.{instance_name}"
                )

            instance_args: typing.Optional[NodeArgs] = None
            if meta.arg_types is not None:
                arg = instance_selection

                if isinstance(arg, tuple):
                    arg = arg[0]

                # arg types are always TypedDicts
                if not isinstance(arg, dict):
                    raise Exception(
                        f"node at {parent_path}.{instance_name} is a node that "
                        + "requires arguments "
                        + f"but detected argument is typeof {type(arg)}"
                    )

                # convert arg dict to NodeArgs
                expected_args = {key: val for key, val in meta.arg_types.items()}
                instance_args = {}
                for key, val in arg.items():
                    ty_name = expected_args.pop(key)
                    if ty_name is None:
                        raise Exception(
                            f"unexpected argument ${key} at {parent_path}.{instance_name}"
                        )
                    instance_args[key] = NodeArgValue(ty_name, val)

            sub_nodes: typing.Optional[typing.List[SelectNode]] = None
            if meta.sub_nodes is not None:
                sub_selections = instance_selection

                # if node requires both selection and arg, it must be
                # a CompositeSelectArgs which is a tuple selection
                if meta.arg_types is not None:
                    if not isinstance(sub_selections, tuple):
                        raise Exception(
                            f"node at {parent_path}.{instance_name} is a composite "
                            + "that requires an argument object "
                            + f"but selection is typeof {type(sub_selections)}"
                        )
                    sub_selections = sub_selections[1]

                elif isinstance(sub_selections, tuple):
                    raise Exception(
                        f"node at {parent_path}.{instance_name} "
                        + "is a composite that takes no arguments "
                        + f"but selection is typeof {type(instance_selection)}",
                    )

                # selection types are always TypedDicts as well
                if not isinstance(sub_selections, dict):
                    raise Exception(
                        f"node at {parent_path}.{instance_name} "
                        + "is a no argument composite but first element of "
                        + f"selection is typeof {type(instance_selection)}",
                    )
                sub_nodes = selection_to_nodes(
                    typing.cast("SelectionGeneric", sub_selections),
                    meta.sub_nodes,
                    f"{parent_path}.{instance_name}",
                )

            node = SelectNode(node_name, instance_name, instance_args, sub_nodes)
            out.append(node)

    found_nodes.discard("_")
    if len(found_nodes) > 0:
        raise Exception(
            f"unexpected nodes found in selection set at {parent_path}: {found_nodes}",
        )
    return out


# Util types section #

Out = typing.TypeVar("Out", covariant=True)

T = typing.TypeVar("T")

ArgT = typing.TypeVar("ArgT", bound=typing.Mapping[str, typing.Any])
SelectionT = typing.TypeVar("SelectionT")

# Query node types section #


@dc.dataclass
class SelectNode(typing.Generic[Out]):
    node_name: str
    instance_name: str
    args: typing.Optional["NodeArgs"]
    sub_nodes: typing.Optional[typing.List["SelectNode"]]


@dc.dataclass
class QueryNode(SelectNode[Out]):
    pass


@dc.dataclass
class MutationNode(SelectNode[Out]):
    pass


@dc.dataclass
class NodeMeta:
    sub_nodes: typing.Optional[typing.Dict[str, typing.Callable[[], "NodeMeta"]]] = None
    arg_types: typing.Optional[typing.Dict[str, str]] = None


# Argument types section #


@dc.dataclass
class NodeArgValue:
    type_name: str
    value: typing.Any


NodeArgs = typing.Dict[str, NodeArgValue]


class PlaceholderValue(typing.Generic[T]):
    def __init__(self, key: str):
        self.key = key


PlaceholderArgs = typing.Dict[str, PlaceholderValue]


class PreparedArgs:
    def get(self, key: str) -> PlaceholderValue:
        return PlaceholderValue(key)


# Selection types section #


class Alias(typing.Generic[SelectionT]):
    """
    Request multiple instances of a single node under different
    aliases.
    """

    def __init__(self, **aliases: SelectionT):
        self.items = aliases


ScalarSelectNoArgs = typing.Union[bool, Alias[typing.Literal[True]], None]
ScalarSelectArgs = typing.Union[
    ArgT,
    PlaceholderArgs,
    Alias[typing.Union[ArgT, PlaceholderArgs]],
    typing.Literal[False],
    None,
]
CompositeSelectNoArgs = typing.Union[
    SelectionT, Alias[SelectionT], typing.Literal[False], None
]
CompositeSelectArgs = typing.Union[
    typing.Tuple[typing.Union[ArgT, PlaceholderArgs], SelectionT],
    Alias[typing.Tuple[typing.Union[ArgT, PlaceholderArgs], SelectionT]],
    typing.Literal[False],
    None,
]


# FIXME: ideally this would be a TypedDict
# to allow full dict based queries but
# we need to reliably identify SelectionFlags at runtime
# but TypedDicts don't allow instanceof
@dc.dataclass
class SelectionFlags:
    select_all: typing.Union[bool, None] = None


class Selection(typing.TypedDict, total=False):
    _: SelectionFlags


SelectionGeneric = typing.Mapping[
    str,
    typing.Union[
        SelectionFlags,
        ScalarSelectNoArgs,
        ScalarSelectArgs[typing.Mapping[str, typing.Any]],
        CompositeSelectNoArgs["SelectionGeneric"],
        # FIXME: should be possible to make SelectionT here `SelectionGeneric` recursively
        # but something breaks
        CompositeSelectArgs[typing.Mapping[str, typing.Any], typing.Any],
    ],
]

# GraphQL section


@dc.dataclass
class GraphQLTransportOptions:
    headers: typing.Dict[str, str]


@dc.dataclass
class GraphQLRequest:
    addr: str
    method: str
    headers: typing.Dict[str, str]
    body: bytes


@dc.dataclass
class GraphQLResponse:
    req: GraphQLRequest
    status: int
    headers: typing.Dict[str, str]
    body: bytes


def convert_query_node_gql(
    node: SelectNode,
    variables: typing.Dict[str, NodeArgValue],
):
    out = (
        f"{node.instance_name}: {node.node_name}"
        if node.instance_name != node.node_name
        else node.node_name
    )
    if node.args is not None:
        arg_row = ""
        for key, val in node.args.items():
            name = f"in{len(variables)}"
            variables[name] = val
            arg_row += f"{key}: ${name}, "
        if len(arg_row):
            out += f"({arg_row[:-2]})"

    if node.sub_nodes is not None:
        sub_node_list = ""
        for node in node.sub_nodes:
            sub_node_list += f"{convert_query_node_gql(node, variables)} "
        out += f" {{ {sub_node_list}}}"
    return out


class GraphQLTransportBase:
    def __init__(
        self,
        addr: str,
        opts: GraphQLTransportOptions,
        ty_to_gql_ty_map: typing.Dict[str, str],
    ):
        self.addr = addr
        self.opts = opts
        self.ty_to_gql_ty_map = ty_to_gql_ty_map

    def build_gql(
        self,
        query: typing.Mapping[str, SelectNode],
        ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]],
        name: str = "",
    ):
        variables: typing.Dict[str, NodeArgValue] = {}
        root_nodes = ""
        for key, node in query.items():
            fixed_node = SelectNode(node.node_name, key, node.args, node.sub_nodes)
            root_nodes += f"  {convert_query_node_gql(fixed_node, variables)}\n"
        args_row = ""
        for key, val in variables.items():
            args_row += f"${key}: {self.ty_to_gql_ty_map[val.type_name]}, "

        if len(args_row):
            args_row = f"({args_row[:-2]})"

        doc = f"{ty} {name}{args_row} {{\n{root_nodes}}}"
        return (doc, {key: val.value for key, val in variables.items()})

    def build_req(
        self,
        doc: str,
        variables: typing.Dict[str, typing.Any],
        opts: typing.Optional[GraphQLTransportOptions] = None,
    ):
        headers = {}
        headers.update(self.opts.headers)
        if opts:
            headers.update(opts.headers)
        headers.update(
            {
                "accept": "application/json",
                "content-type": "application/json",
            }
        )
        data = json.dumps({"query": doc, "variables": variables}).encode("utf-8")
        return GraphQLRequest(
            addr=self.addr,
            method="POST",
            headers=headers,
            body=data,
        )

    def handle_response(self, res: GraphQLResponse):
        if res.status != 200:
            raise Exception(f"graphql request failed with status {res.status}", res)
        if res.headers.get("content-type") != "application/json":
            raise Exception("unexpected content-type in graphql response", res)
        parsed = json.loads(res.body)
        if parsed.get("errors"):
            raise Exception("graphql errors in response", parsed)
        return parsed["data"]


class GraphQLTransportUrlib(GraphQLTransportBase):
    def fetch(
        self,
        doc: str,
        variables: typing.Dict[str, typing.Any],
        opts: typing.Optional[GraphQLTransportOptions],
    ):
        req = self.build_req(doc, variables, opts)
        try:
            with request.urlopen(
                request.Request(
                    url=req.addr, method=req.method, headers=req.headers, data=req.body
                )
            ) as res:
                http_res: http_c.HTTPResponse = res
                return self.handle_response(
                    GraphQLResponse(
                        req,
                        status=http_res.status,
                        body=http_res.read(),
                        headers={key: val for key, val in http_res.headers.items()},
                    )
                )
        except request.HTTPError as res:
            return self.handle_response(
                GraphQLResponse(
                    req,
                    status=res.status or 599,
                    body=res.read(),
                    headers={key: val for key, val in res.headers.items()},
                )
            )
        except urllib.error.URLError as err:
            raise Exception(f"URL error: {err.reason}")

    def query(
        self,
        inp: typing.Dict[str, QueryNode[Out]],
        opts: typing.Optional[GraphQLTransportOptions] = None,
        name: str = "",
    ) -> typing.Dict[str, Out]:
        doc, variables = self.build_gql(
            {key: val for key, val in inp.items()}, "query", name
        )
        # print(doc,variables)
        # return {}
        return self.fetch(doc, variables, opts)

    def mutation(
        self,
        inp: typing.Dict[str, MutationNode[Out]],
        opts: typing.Optional[GraphQLTransportOptions] = None,
        name: str = "",
    ) -> typing.Dict[str, Out]:
        doc, variables = self.build_gql(
            {key: val for key, val in inp.items()}, "mutation", name
        )
        return self.fetch(doc, variables, opts)

    def prepare_query(
        self,
        fun: typing.Callable[[PreparedArgs], typing.Dict[str, QueryNode[Out]]],
        name: str = "",
    ) -> "PreparedRequestUrlib[Out]":
        return PreparedRequestUrlib(self, fun, "query", name)

    def prepare_mutation(
        self,
        fun: typing.Callable[[PreparedArgs], typing.Dict[str, MutationNode[Out]]],
        name: str = "",
    ) -> "PreparedRequestUrlib[Out]":
        return PreparedRequestUrlib(self, fun, "mutation", name)


class PreparedRequestBase(typing.Generic[Out]):
    def __init__(
        self,
        transport: GraphQLTransportBase,
        fun: typing.Callable[[PreparedArgs], typing.Mapping[str, SelectNode[Out]]],
        ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]],
        name: str = "",
    ):
        dry_run_node = fun(PreparedArgs())
        doc, variables = transport.build_gql(dry_run_node, ty, name)
        self.doc = doc
        self._mapping = variables
        self.transport = transport

    def resolve_vars(
        self,
        args: typing.Mapping[str, typing.Any],
        mappings: typing.Dict[str, typing.Any],
    ):
        resolved: typing.Dict[str, typing.Any] = {}
        for key, val in mappings.items():
            if isinstance(val, PlaceholderValue):
                resolved[key] = args[val.key]
            elif isinstance(val, dict):
                self.resolve_vars(args, val)
            else:
                resolved[key] = val
        return resolved


class PreparedRequestUrlib(PreparedRequestBase[Out]):
    def __init__(
        self,
        transport: GraphQLTransportUrlib,
        fun: typing.Callable[[PreparedArgs], typing.Mapping[str, SelectNode[Out]]],
        ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]],
        name: str = "",
    ):
        super().__init__(transport, fun, ty, name)
        self.transport = transport

    def perform(
        self,
        args: typing.Mapping[str, typing.Any],
        opts: typing.Optional[GraphQLTransportOptions] = None,
    ) -> typing.Dict[str, Out]:
        resolved_vars = self.resolve_vars(args, self._mapping)
        return self.transport.fetch(self.doc, resolved_vars, opts)


# Query graph section #


class QueryGraphBase:
    def __init__(self, ty_to_gql_ty_map: typing.Dict[str, str]):
        self.ty_to_gql_ty_map = ty_to_gql_ty_map

    def graphql_sync(
        self, addr: str, opts: typing.Optional[GraphQLTransportOptions] = None
    ):
        return GraphQLTransportUrlib(
            addr, opts or GraphQLTransportOptions({}), self.ty_to_gql_ty_map
        )


# - - - - - - - - - -- - - - - - -  -- - -  #


class NodeDescs:
    @staticmethod
    def scalar():
        return NodeMeta()

    @staticmethod
    def Post():
        return NodeMeta(
            sub_nodes={
                "id": NodeDescs.scalar,
                "slug": NodeDescs.scalar,
                "title": NodeDescs.scalar,
            },
        )

    @staticmethod
    def User():
        return NodeMeta(
            sub_nodes={
                "id": NodeDescs.scalar,
                "email": NodeDescs.scalar,
                "posts": NodeDescs.Post,
            },
        )

    @staticmethod
    def Func23():
        return NodeMeta(
            sub_nodes=NodeDescs.User().sub_nodes,
        )

    @staticmethod
    def Func25():
        return NodeMeta(
            sub_nodes=NodeDescs.scalar().sub_nodes,
        )

    @staticmethod
    def Func26():
        return NodeMeta(
            sub_nodes=NodeDescs.scalar().sub_nodes,
            arg_types={
                "id": "String4",
                "slug": "String1",
                "title": "String1",
            },
        )

    @staticmethod
    def Func27():
        return NodeMeta(
            sub_nodes=NodeDescs.Post().sub_nodes,
        )

    @staticmethod
    def Func28():
        return NodeMeta(
            sub_nodes=NodeDescs.Post().sub_nodes,
            arg_types={
                "id": "String13",
            },
        )

    @staticmethod
    def Func24():
        return NodeMeta(
            sub_nodes=NodeDescs.Post().sub_nodes,
        )


Object21 = typing.TypedDict(
    "Object21",
    {
        "id": str,
    },
    total=False,
)

StringUuid = str

Post = typing.TypedDict(
    "Post",
    {
        "id": StringUuid,
        "slug": str,
        "title": str,
    },
    total=False,
)

StringEmail = str

Post7 = typing.List[Post]

User = typing.TypedDict(
    "User",
    {
        "id": StringUuid,
        "email": StringEmail,
        "posts": Post7,
    },
    total=False,
)


PostSelections = typing.TypedDict(
    "PostSelections",
    {
        "_": SelectionFlags,
        "id": ScalarSelectNoArgs,
        "slug": ScalarSelectNoArgs,
        "title": ScalarSelectNoArgs,
    },
    total=False,
)

UserSelections = typing.TypedDict(
    "UserSelections",
    {
        "_": SelectionFlags,
        "id": ScalarSelectNoArgs,
        "email": ScalarSelectNoArgs,
        "posts": CompositeSelectNoArgs["PostSelections"],
    },
    total=False,
)


class QueryGraph(QueryGraphBase):
    def __init__(self):
        super().__init__(
            {
                "String4": "Any",
                "String1": "Any",
                "String13": "Any",
            }
        )

    def get_user(self, select: UserSelections) -> QueryNode[User]:
        node = selection_to_nodes(
            {"getUser": select}, {"getUser": NodeDescs.Func23}, "$q"
        )[0]
        return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes)

    def get_posts(self, select: PostSelections) -> QueryNode[Post]:
        node = selection_to_nodes(
            {"getPosts": select}, {"getPosts": NodeDescs.Func24}, "$q"
        )[0]
        return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes)

    def scalar_no_args(self) -> QueryNode[str]:
        node = selection_to_nodes(
            {"scalarNoArgs": True}, {"scalarNoArgs": NodeDescs.Func25}, "$q"
        )[0]
        return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes)

    def scalar_args(
        self, args: typing.Union[Post, PlaceholderArgs]
    ) -> MutationNode[str]:
        node = selection_to_nodes(
            {"scalarArgs": args}, {"scalarArgs": NodeDescs.Func26}, "$q"
        )[0]
        return MutationNode(
            node.node_name, node.instance_name, node.args, node.sub_nodes
        )

    def composite_no_args(self, select: PostSelections) -> MutationNode[Post]:
        node = selection_to_nodes(
            {"compositeNoArgs": select}, {"compositeNoArgs": NodeDescs.Func27}, "$q"
        )[0]
        return MutationNode(
            node.node_name, node.instance_name, node.args, node.sub_nodes
        )

    def composite_args(
        self, args: typing.Union[Object21, PlaceholderArgs], select: PostSelections
    ) -> MutationNode[Post]:
        node = selection_to_nodes(
            {"compositeArgs": (args, select)}, {"compositeArgs": NodeDescs.Func28}, "$q"
        )[0]
        return MutationNode(
            node.node_name, node.instance_name, node.args, node.sub_nodes
        )

@Yohe-Am Yohe-Am mentioned this pull request Aug 1, 2024
3 tasks
Yohe-Am added a commit that referenced this pull request Aug 7, 2024
- Implements `client_py` as described in #777

This is a stacked PR on top of #790.

#### Migration notes

...

- [ ] The change comes with new or modified tests
- [ ] Hard-to-understand functions have explanatory comments
- [ ] End-user documentation is updated to reflect the change

---------

Co-authored-by: Teo Stocco <[email protected]>
Co-authored-by: Estifanos Bireda <[email protected]>
Co-authored-by: FITAHIANA Nomeniavo joe <[email protected]>
commit 25d96a8
Author: Yohe-Am <[email protected]>
Date:   Wed Aug 7 07:21:33 2024 +0300

    refactor: PreparedArgs api

commit 278bc09
Author: Yohe-Am <[email protected]>
Date:   Wed Aug 7 03:10:16 2024 +0300

    test: alias tests

commit 8ff17ec
Author: Yohe-Am <[email protected]>
Date:   Fri Aug 2 08:46:56 2024 +0300

    fix: test

commit 62d534d
Merge: d1c532e 4c30067
Author: Yohe-Am <[email protected]>
Date:   Fri Aug 2 06:46:33 2024 +0300

    Merge branch 'main' into feat/MET-567/python-gen-client

commit d1c532e
Author: Yohe-Am <[email protected]>
Date:   Fri Aug 2 04:30:28 2024 +0300

    tests: client_py

commit 676bdb8
Author: Yohe-Am <[email protected]>
Date:   Fri Aug 2 03:58:03 2024 +0300

    tests: client_py

commit 4c30067
Author: Yohe-Am <[email protected]>
Date:   Thu Aug 1 11:36:14 2024 +0300

    chore: prepare 0.4.6 (#795)

    - Bump version to 0.4.6-0
    - Add sanity tests for published SDKs
    - Bump deno to 1.45.2
    - Bump rust to 1.79.0
    - Fix myriad of bugs

    #### Migration notes

    ...

    - [x] The change comes with new or modified tests
    - [ ] Hard-to-understand functions have explanatory comments
    - [ ] End-user documentation is updated to reflect the change

    <!-- This is an auto-generated comment: release notes by coderabbit.ai
    -->

    ## Summary by CodeRabbit

    - **New Features**
    - Introduced new logging capabilities in the `ConnectedEngine` with
    adjustable logging levels.
    - Implemented cleanup procedures in tests to enhance resource
    management.

    - **Bug Fixes**
    - Fixed import paths for permissions to ensure correct functionality in
    tests and applications.

    - **Version Updates**
    - Incremented version numbers across multiple projects and packages to
    reflect ongoing development and improvements.

    - **Documentation**
    - Added comments to clarify code behavior and potential future
    considerations in various modules.

    - **Refactor**
    - Optimized string handling in several functions and adjusted method
    signatures for improved clarity and efficiency.

    <!-- end of auto-generated comment: release notes by coderabbit.ai -->

commit f0ecfb5
Author: Yohe-Am <[email protected]>
Date:   Thu Aug 1 08:01:10 2024 +0300

    wip: wip

commit a48203a
Author: Yohe-Am <[email protected]>
Date:   Thu Aug 1 06:15:12 2024 +0300

    wip: wip

commit bfee2f2
Author: Yohe-Am <[email protected]>
Date:   Thu Aug 1 05:22:51 2024 +0300

    wip: wip

commit e57c322
Author: Yohe-Am <[email protected]>
Date:   Wed Jul 31 19:16:23 2024 +0300

    wip: wip

commit f55a3e7
Author: Yohe-Am <[email protected]>
Date:   Wed Jul 31 13:37:01 2024 +0300

    wip: wip

commit 25d5c24
Author: FITAHIANA Nomeniavo joe <[email protected]>
Date:   Thu Jul 25 20:59:22 2024 +0300

    feat:  better arg split logic for MCLI_LOADER (#799)

    <!--
    Pull requests are squashed and merged using:
    - their title as the commit message
    - their description as the commit body

    Having a good title and description is important for the users to get
    readable changelog.
    -->

    <!-- 1. Explain WHAT the change is about -->

    -

    <!-- 2. Explain WHY the change cannot be made simpler -->

    -

    <!-- 3. Explain HOW users should update their code -->

    #### Migration notes

    ...

    - [ ] The change comes with new or modified tests
    - [ ] Hard-to-understand functions have explanatory comments
    - [ ] End-user documentation is updated to reflect the change
michael-0acf4
michael-0acf4 previously approved these changes Aug 7, 2024
@Yohe-Am Yohe-Am mentioned this pull request Aug 12, 2024
3 tasks
- Implement `client_rs` as described in #777 
- Bump ghjk to v0.2.1

This PR is a stacked on top of #790 

- [x] The change comes with new or modified tests
- [x] Hard-to-understand functions have explanatory comments
- [ ] End-user documentation is updated to reflect the change
@Yohe-Am
Copy link
Contributor Author

Yohe-Am commented Aug 15, 2024

@coderabbitai review

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 21

Outside diff range, codebase verification and nitpick comments (14)
libs/metagen/src/client_py/utils.rs (1)

20-32: TODO for Cleaning Non-Valid Characters Remains Unaddressed

The normalize_type_title function is used in various parts of the codebase, but there is no evidence of additional handling for non-valid characters. The TODO comment in the function should be addressed to ensure complete normalization.

Analysis chain

Normalization of type titles is well-implemented, but address TODO.

The normalize_type_title function effectively transforms titles to PascalCase and moves leading numbers to the end. However, the TODO comment about cleaning non-valid characters should be addressed to ensure complete normalization.

Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Verify usage of `normalize_type_title` function and check for handling of non-valid characters.

# Test: Search for function usage. Expect: Ensure non-valid characters are handled.
ast-grep --lang rust --pattern 'normalize_type_title($_)' --json

Length of output: 22570


Script:

#!/bin/bash
# Search for comments or functions related to cleaning non-valid characters in the files where `normalize_type_title` is used.
rg --context 5 "clean" libs/metagen/src/client_ts/selections.rs libs/metagen/src/client_ts/node_metas.rs libs/metagen/src/client_ts/mod.rs libs/metagen/src/mdk_typescript/types.rs libs/metagen/src/client_rs/node_metas.rs libs/metagen/src/client_rs/selections.rs libs/metagen/src/client_rs/mod.rs libs/metagen/src/client_py/mod.rs libs/metagen/src/client_py/selections.rs libs/metagen/src/client_py/node_metas.rs libs/metagen/src/mdk_rust/stubs.rs libs/metagen/src/client_py/types.rs libs/metagen/src/mdk_rust/types.rs

Length of output: 517

libs/metagen/fixtures/client_ts/main.ts (1)

47-48: Avoid redundant calls to preparedQ.perform().

The calls to preparedQ.perform({}) on lines 47 and 48 are identical. If both are necessary for testing different scenarios, consider adding comments to clarify their purpose. Otherwise, remove the redundancy.

- const res1 = await preparedQ.perform({});
- const res1a = await preparedQ.perform({});
+ const res1 = await preparedQ.perform({});
typegate/tests/metagen/typegraphs/sample/ts/main.ts (1)

47-48: Avoid redundant calls to preparedQ.perform().

The calls to preparedQ.perform({}) on lines 47 and 48 are identical. If both are necessary for testing different scenarios, consider adding comments to clarify their purpose. Otherwise, remove the redundancy.

- const res1 = await preparedQ.perform({});
- const res1a = await preparedQ.perform({});
+ const res1 = await preparedQ.perform({});
libs/metagen/src/tests/fixtures.rs (1)

10-10: Update file path in test_typegraph_1.

The file tg.ts is located at libs/metagen/fixtures/tg.ts. Ensure that the command in test_typegraph_1 uses this full path to correctly reference the file.

  • Update command to use libs/metagen/fixtures/tg.ts instead of fixtures/tg.ts.
Analysis chain

Update file path in test_typegraph_1.

The file path in the cargo run command has been changed from tests/tg.ts to fixtures/tg.ts. This likely reflects a reorganization of test files. Ensure that the new path is correct and accessible.

Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Verify the existence and accessibility of the updated file path.

# Test: Check if the file `fixtures/tg.ts` exists.
# Expect: File should exist.
fd 'tg.ts' --search-path='fixtures'

Length of output: 129


Script:

#!/bin/bash
# Search for the file `tg.ts` in the entire repository to find its correct location.
fd 'tg.ts'

Length of output: 38

libs/metagen/fixtures/client_rs/main.rs (2)

104-132: Consider logging the results for better traceability.

The code prints the results of multiple GraphQL operations. Consider adding logging to capture these results for better traceability and debugging.

// Example: Adding logging for results
println!("GraphQL operation results: {}", serde_json::to_string_pretty(&serde_json::json!([
  // existing code...
]))?);

10-12: Ensure TG_PORT environment variable is set

The TG_PORT environment variable is not currently set, which is necessary for constructing the API endpoint URL. Please ensure that this environment variable is configured in all environments where the code will be executed.

Analysis chain

Ensure environment variable TG_PORT is set.

The code retrieves the TG_PORT environment variable, which is crucial for constructing the API endpoint. Ensure that this environment variable is set in all environments where this code will run.

Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Verify that the TG_PORT environment variable is set.

# Test: Check if TG_PORT is set. Expect: Non-empty output if set.
echo "TG_PORT: ${TG_PORT:-not set}"

Length of output: 42

typegate/tests/metagen/typegraphs/identities/rs/mdk.rs (1)

112-112: Inconsistent MT_VERSION Across Codebase

The MT_VERSION variable has been updated to "0.4.8-0" in typegate/tests/metagen/typegraphs/identities/rs/mdk.rs. However, other files, such as typegate/tests/runtimes/wasm_wire/rust/mdk.rs, still use "0.4.6", and libs/metagen/src/mdk_rust/static/mdk.rs uses a placeholder. To ensure compatibility, please update all instances of MT_VERSION to "0.4.8-0".

  • typegate/tests/runtimes/wasm_wire/rust/mdk.rs: Update from "0.4.6" to "0.4.8-0".
  • libs/metagen/src/mdk_rust/static/mdk.rs: Replace the placeholder with "0.4.8-0".
Analysis chain

Update MT_VERSION to 0.4.8-0.

The static MT_VERSION has been updated to "0.4.8-0", reflecting a newer version for compatibility checks. Ensure that all dependencies and integrations align with this version change.

Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Verify usage of MT_VERSION across the codebase to ensure compatibility.

# Test: Search for MT_VERSION usage. Expect: Consistent usage with the updated version.
rg --type rust 'MT_VERSION'

Length of output: 1235

typegate/tests/metagen/metagen_test.ts (1)

11-12: Consider Importing Only Used Functions from dax and zod.

To improve clarity and potentially reduce bundle size, consider importing only the specific functions or classes used from dax and zod.

libs/metagen/src/client_rs/mod.rs (2)

21-32: Validate Configuration Struct Fields.

The ClienRsGenConfig struct uses garde for validation. Ensure that all fields are correctly validated and consider adding documentation for each field to clarify its purpose.


34-45: Enhance Error Handling for from_json Method

The current implementation of the from_json method relies on anyhow for error propagation without additional context or logging. Consider enhancing the error handling by adding more informative error messages or logging to aid in debugging. This will make the method more robust and user-friendly.

  • File: libs/metagen/src/client_rs/mod.rs
  • Method: from_json
Analysis chain

Ensure Robust Error Handling in from_json.

The from_json method should handle potential errors robustly, especially when dealing with file paths and JSON parsing. Consider logging errors or providing more context in error messages.

Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Verify error handling in `from_json`.

# Test: Check for error handling in the `from_json` method. Expect: Robust handling and informative error messages.
rg --type rust -A 5 'fn from_json' libs/metagen/src/client_rs/mod.rs

Length of output: 395


Script:

#!/bin/bash
# Search for logging or additional context handling related to the `from_json` method.
rg 'from_json' -A 5 --type rust

Length of output: 6004

libs/metagen/src/client_ts/static/mod.ts (1)

369-369: Consider removing the commented-out console log.

The commented-out console log might be useful for debugging but should be removed in production code.

-  // console.log(doc, variables);
libs/metagen/src/mdk_rust/types.rs (2)

93-94: Clarify the purpose of body_required.

The variable body_required is used to determine if a type alias should be generated. Consider adding a comment to explain its role in the rendering process.

// Determine if a type alias is necessary based on the node's requirements
let body_required = type_body_required(cursor.node.clone());

103-108: Typographical errors in comments.

There are typographical errors in the comments, such as "primitie" and "defualt". Correct these to improve readability.

// if [type_body_required] says so, we usually need to generate
// aliases for even simple primitive types
// under certain conditions, we don't want to generate aliases
// for primitive types. this includes
// - types with default generated names
// - types with no special semantics
libs/metagen/src/client_rs/static/client.rs (1)

1188-1230: Improve error messages in handle_response.

The error messages could be more descriptive to aid debugging. Consider including more context, such as the response content or the URL.

return Err(GraphQLRequestError::RequestFailed { 
    response: GraphQLResponse {
        status: response.status,
        headers: response.headers.clone(),
        body: response.body.clone(),
    },
    message: format!("Request failed for URL: {}", response.url),
});

libs/metagen/src/client_py/utils.rs Show resolved Hide resolved
libs/metagen/fixtures/client_ts/main.ts Show resolved Hide resolved
libs/metagen/src/client_rs/node_metas.rs Outdated Show resolved Hide resolved
libs/metagen/fixtures/tg2.ts Show resolved Hide resolved
typegate/tests/metagen/typegraphs/sample/ts/client.ts Outdated Show resolved Hide resolved
libs/metagen/src/client_py/static/client.py Show resolved Hide resolved
libs/metagen/src/client_ts/node_metas.rs Show resolved Hide resolved
libs/metagen/src/client_ts/node_metas.rs Show resolved Hide resolved
Copy link
Member

@zifeo zifeo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Massive, lgtm 👍 For the docs update, I suppose you will update the previous PR about them?

libs/metagen/src/client_py/types.rs Show resolved Hide resolved
libs/metagen/src/client_rs/static/client.rs Show resolved Hide resolved
libs/metagen/src/client_ts/selections.rs Show resolved Hide resolved
@Yohe-Am Yohe-Am merged commit 51f20a0 into main Aug 25, 2024
12 checks passed
@Yohe-Am Yohe-Am deleted the feat/MET-609/clien-ts-gen branch August 25, 2024 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants