Skip to content

Commit

Permalink
Positional params (#149)
Browse files Browse the repository at this point in the history
* Positional params

* Update changelog

* Add backtick strings to changelog

* Cleanup
  • Loading branch information
captbaritone authored Aug 15, 2024
1 parent 6233439 commit a8ec3e2
Show file tree
Hide file tree
Showing 63 changed files with 1,124 additions and 232 deletions.
61 changes: 57 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,63 @@

Changes in this section are not yet released. If you need access to these changes before we cut a release, check out our `@main` NPM releases. Each commit on the main branch is [published to NPM](https://www.npmjs.com/package/grats?activeTab=versions) under the `main` tag.

- **Features**
- Functional fields can now be defined using exported arrow functions.
- **Bug Fixes**
- ...
### Positional Arguments

Field arguments can now be defined using regular TypeScript arguments rather requiring all GraphQL arguments to be grouped together in a single object.

```ts
/** @gqlType */
class Query {
/** @gqlField */
userById(_: Query, id: string): User {
return DB.getUserById(id);
}
}
```

The improved ergonomics of this approach are especially evident when defining arguments with default values:

```ts
/** @gqlType */
class Query {
// OLD STYLE
/** @gqlField */
greeting(_: Query, { salutation = "Hello" }: { salutation: string }): string {
return `${salutation} World`;
}

// NEW STYLE
/** @gqlField */
greeting(_: Query, salutation: string = "Hello"): string {
return `${salutation} World`;
}
}
```

### Arrow function fields

Fields can now be defined using arrow functions:

```ts
/** @gqlField */
export const userById = (_: Query, id: string): User => {
return DB.getUserById(id);
};
```

### Backtick strings

Backtick strings are now correctly parsed as strings literals, as long as they are not used as template strings. For example `\`Hello\`` in the following example:

```ts
/** @gqlType */
class Query {
/** @gqlField */
greeting(_: Query, salutation: string = `Hello`): string {
return `${salutation} World`;
}
}
```

## 0.0.27

Expand Down
4 changes: 2 additions & 2 deletions examples/incremental-migration/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ faker.seed(123);
type Query = unknown;

/** @gqlField */
export function user(_: Query, args: { id: ID }): User | undefined {
return Users.get(args.id);
export function user(_: Query, id: ID): User | undefined {
return Users.get(id);
}

/** @gqlType */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function getSchema(): GraphQLSchema {
}
},
resolve(source, args) {
return queryUserResolver(source, args);
return queryUserResolver(source, args.id);
}
}
};
Expand Down
6 changes: 3 additions & 3 deletions examples/production-app/models/Like.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ type CreateLikePayload = {
* @gqlField */
export async function createLike(
_: Mutation,
args: { input: CreateLikeInput },
input: CreateLikeInput,
ctx: Ctx,
): Promise<CreateLikePayload> {
const id = getLocalTypeAssert(args.input.postId, "Post");
const id = getLocalTypeAssert(input.postId, "Post");
await DB.createLike(ctx.vc, {
...args.input,
...input,
userId: ctx.vc.userId(),
postId: id,
});
Expand Down
4 changes: 2 additions & 2 deletions examples/production-app/models/LikeConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ export async function likes(
* @gqlField */
export async function postLikes(
_: Subscription,
args: { postID: string },
postID: string,
ctx: Ctx,
info: GqlInfo,
): Promise<AsyncIterable<LikeConnection>> {
const id = getLocalTypeAssert(args.postID, "Post");
const id = getLocalTypeAssert(postID, "Post");
const post = await ctx.vc.getPostById(id);
return pipe(
PubSub.subscribe("postLiked"),
Expand Down
8 changes: 4 additions & 4 deletions examples/production-app/models/Post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,13 @@ function serializeContent(content: PostContentInput): string {
* @gqlField */
export async function createPost(
_: Mutation,
args: { input: CreatePostInput },
input: CreatePostInput,
ctx: Ctx,
): Promise<CreatePostPayload> {
const post = await DB.createPost(ctx.vc, {
...args.input,
content: serializeContent(args.input.content),
authorId: getLocalTypeAssert(args.input.authorId, "User"),
...input,
content: serializeContent(input.content),
authorId: getLocalTypeAssert(input.authorId, "User"),
});
return { post };
}
4 changes: 2 additions & 2 deletions examples/production-app/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ type CreateUserPayload = {
* @gqlField */
export async function createUser(
_: Mutation,
args: { input: CreateUserInput },
input: CreateUserInput,
ctx: Ctx,
): Promise<CreateUserPayload> {
const user = await DB.createUser(ctx.vc, args.input);
const user = await DB.createUser(ctx.vc, input);
return { user };
}
8 changes: 4 additions & 4 deletions examples/production-app/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ export function getSchema(): GraphQLSchema {
}
},
resolve(source, args, context) {
return mutationCreateLikeResolver(source, args, context);
return mutationCreateLikeResolver(source, args.input, context);
}
},
createPost: {
Expand All @@ -629,7 +629,7 @@ export function getSchema(): GraphQLSchema {
}
},
resolve(source, args, context) {
return mutationCreatePostResolver(source, args, context);
return mutationCreatePostResolver(source, args.input, context);
}
},
createUser: {
Expand All @@ -643,7 +643,7 @@ export function getSchema(): GraphQLSchema {
}
},
resolve(source, args, context) {
return mutationCreateUserResolver(source, args, context);
return mutationCreateUserResolver(source, args.input, context);
}
}
};
Expand All @@ -664,7 +664,7 @@ export function getSchema(): GraphQLSchema {
}
},
subscribe(source, args, context, info) {
return subscriptionPostLikesResolver(source, args, context, info);
return subscriptionPostLikesResolver(source, args.postID, context, info);
},
resolve(payload) {
return payload;
Expand Down
15 changes: 14 additions & 1 deletion src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export function pluralTypeMissingParameter() {
}

export function expectedNameIdentifier() {
return "Expected an name identifier. Grats expected to find a name here which it could use to derive the GraphQL name.";
return "Expected a name identifier. Grats expected to find a name here which it could use to derive the GraphQL name.";
}

// TODO: Add code action
Expand Down Expand Up @@ -577,3 +577,16 @@ export function fieldVariableNotTopLevelExported(): string {
export function fieldVariableIsNotArrowFunction(): string {
return `Expected \`@${FIELD_TAG}\` on variable declaration to be attached to an arrow function.`;
}

export function positionalResolverArgDoesNotHaveName(): string {
return "Expected resolver argument to have a name. Grats needs to be able to see the name of the argument in order to derive a GraphQL argument name.";
}

export function positionalArgAndArgsObject(): string {
return "Unexpected arguments object in resolver that is also using positional GraphQL arguments. Grats expects that either all GraphQL arguments will be defined in a single object, or that all GraphQL arguments will be defined using positional arguments. The two strategies may not be combined.";
}

export function contextOrInfoUsedInGraphQLPosition(kind: "CONTEXT" | "INFO") {
const tag = kind === "CONTEXT" ? CONTEXT_TAG : INFO_TAG;
return `Cannot use \`${tag}\` as a type in GraphQL type position.`;
}
95 changes: 71 additions & 24 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
tsRelated,
DiagnosticsResult,
gqlErr,
DiagnosticResult,
} from "./utils/DiagnosticError";
import { err, ok } from "./utils/Result";
import * as ts from "typescript";
Expand All @@ -33,9 +34,12 @@ import { GraphQLConstructor } from "./GraphQLConstructor";
import { relativePath } from "./gratsRoot";
import { ISSUE_URL } from "./Errors";
import { detectInvalidComments } from "./comments";
import { extend, loc } from "./utils/helpers";
import { extend, invariant, loc } from "./utils/helpers";
import * as Act from "./CodeActions";
import { UnresolvedResolverParam } from "./metadataDirectives.js";
import {
InputValueDefinitionNodeOrResolverArg,
UnresolvedResolverParam,
} from "./metadataDirectives.js";

export const LIBRARY_IMPORT_NAME = "grats";
export const LIBRARY_NAME = "Grats";
Expand Down Expand Up @@ -617,7 +621,10 @@ class Extractor {
args,
directives,
description,
[{ kind: "named", name: "source" }, ...resolverParams],
[
{ kind: "named", name: "source", sourceNode: typeParam },
...resolverParams,
],
);
this.definitions.push(
this.gql.abstractFieldDefinition(node, typeName, field),
Expand Down Expand Up @@ -1904,7 +1911,7 @@ class Extractor {
tsRelated(args.param, "Previous type literal"),
]);
}
resolverParams.push({ kind: "named", name: "args" });
resolverParams.push({ kind: "named", name: "args", sourceNode: param });
args = { param, inputs: [] };

let defaults: ArgDefaults | null = null;
Expand All @@ -1920,30 +1927,70 @@ class Extractor {
}
continue;
}
if (ts.isTypeReferenceNode(param.type)) {
const namedTypeNode = this.gql.namedType(
param.type,
UNRESOLVED_REFERENCE_NAME,
);
this.markUnresolvedType(param.type.typeName, namedTypeNode.name);
resolverParams.push({ kind: "unresolved", namedTypeNode });
continue;
}
// We handle a few special cases of unexpected types here to provide
// more helpful error messages.
if (param.type.kind === ts.SyntaxKind.UnknownKeyword) {
// TODO: Offer code action?
return this.report(param.type, E.resolverParamIsUnknown());
}
if (param.type.kind === ts.SyntaxKind.NeverKeyword) {
// TODO: Offer code action?
return this.report(param.type, E.resolverParamIsNever());
}
return this.report(param.type, E.unexpectedResolverParamType());

const inputDefinition = this.collectParamArg(param);
if (inputDefinition == null) return null;
resolverParams.push({ kind: "unresolved", inputDefinition });
}
return { resolverParams, args: args ? args.inputs : null };
}

collectParamArg(
param: ts.ParameterDeclaration,
): InputValueDefinitionNodeOrResolverArg | null {
invariant(param.type != null, "Expected type annotation");

// This param might be info or context, in which case we don't need a name,
// or it might be a GraphQL argument, in which case we _do_ need a name.
// However, we don't know which we have until a later phase where were are
// type-aware.
// By modeling the name as a diagnostic result, we can defer the decision
// of whether the name is required until we have more information.
let name: DiagnosticResult<NameNode> = ts.isIdentifier(param.name)
? ok(this.gql.name(param.name, param.name.text))
: err(tsErr(param.name, E.positionalResolverArgDoesNotHaveName()));

const type = this.collectType(param.type, { kind: "INPUT" });
if (type == null) return null;

let defaultValue: ConstValueNode | null = null;

if (param.initializer != null) {
defaultValue = this.collectConstValue(param.initializer);
}

if (param.questionToken) {
// Question mark means we can handle the argument being undefined in the
// object literal, but if we are going to type the GraphQL arg as
// optional, the code must also be able to handle an explicit null.
//
// In the object map args case we have to consider the possibility of a
// default value, but TS does not allow default value for optional args,
// so TS will take care of that for us.
if (type.kind === Kind.NON_NULL_TYPE) {
// This is only a problem if the type turns out to be a GraphQL type.
// If it's info or context, it's fine. So, we defer the error until
// later when we try to use this as a GraphQL type.
if (name.kind === "OK") {
name = err(
tsErr(param.questionToken, E.nonNullTypeCannotBeOptional()),
);
}
}
}

const deprecated = this.collectDeprecated(param);

return this.gql.inputValueDefinitionOrResolverArg(
param,
name,
type,
deprecated == null ? null : [deprecated],
defaultValue,
this.collectDescription(param),
);
}

modifiersAreValidForField(
node: ts.MethodDeclaration | ts.MethodSignature | ts.GetAccessorDeclaration,
): boolean {
Expand Down
22 changes: 21 additions & 1 deletion src/GraphQLConstructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ import {
FIELD_METADATA_DIRECTIVE,
EXPORT_NAME_ARG,
UnresolvedResolverParam,
InputValueDefinitionNodeOrResolverArg,
} from "./metadataDirectives";
import { uniqueId } from "./utils/helpers";
import { TsLocatableNode } from "./utils/DiagnosticError";
import { DiagnosticResult, TsLocatableNode } from "./utils/DiagnosticError";

export class GraphQLConstructor {
fieldMetadataDirective(
Expand Down Expand Up @@ -232,6 +233,25 @@ export class GraphQLConstructor {
};
}

inputValueDefinitionOrResolverArg(
node: ts.Node,
name: DiagnosticResult<NameNode>,
type: TypeNode,
directives: readonly ConstDirectiveNode[] | null,
defaultValue: ConstValueNode | null,
description: StringValueNode | null,
): InputValueDefinitionNodeOrResolverArg {
return {
kind: Kind.INPUT_VALUE_DEFINITION,
loc: loc(node),
description: description ?? undefined,
name,
type,
defaultValue: defaultValue ?? undefined,
directives: this._optionalList(directives),
};
}

enumValueDefinition(
node: ts.Node,
name: NameNode,
Expand Down
Loading

0 comments on commit a8ec3e2

Please sign in to comment.