Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export function argNotTyped() {
}

export function enumTagOnInvalidNode() {
return `Expected \`@${ENUM_TAG}\` to be a union type, or a string literal in the edge case of a single value enum. For example: \`type MyEnum = "foo" | "bar"\` or \`type MyEnum = "foo"\`.`;
return `Expected \`@${ENUM_TAG}\` to be a union type, a string literal in the edge case of a single value enum, or a const array member type query. For example: \`type MyEnum = "foo" | "bar"\`, \`type MyEnum = "foo"\`, or \`const VALUES = ["foo", "bar"] as const; type MyEnum = typeof VALUES[number]\`.`;
}

export function enumVariantNotStringLiteral() {
Expand All @@ -247,6 +247,14 @@ export function enumVariantMissingInitializer() {
return `Expected \`@${ENUM_TAG}\` enum members to have string literal initializers. For example: \`FOO = 'foo'\`. In GraphQL enum values are strings, and Grats needs to be able to see the concrete value of the enum member to generate the GraphQL schema.`;
}

export function enumTypeAliasConstArrayEmpty() {
return `Expected \`@${ENUM_TAG}\` const array to include at least one string literal value. For example: \`const VALUES = ["foo"] as const; type MyEnum = typeof VALUES[number]\`.`;
}

export function enumVariantDuplicateValue(value: string) {
return `Expected \`@${ENUM_TAG}\` enum members to be unique. Found duplicate value \`${value}\`.`;
}

export function gqlEntityMissingName() {
return "Expected GraphQL entity to have a name. Grats uses the name of the entity to derive the name of the GraphQL construct.";
}
Expand Down
151 changes: 151 additions & 0 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2170,6 +2170,10 @@ class Extractor {
];
}

if (ts.isIndexedAccessTypeNode(node.type)) {
return this.enumTypeAliasConstArrayVariants(node, node.type);
}

if (!ts.isUnionTypeNode(node.type)) {
this.reportUnhandled(node.type, "union", E.enumTagOnInvalidNode());
return null;
Expand Down Expand Up @@ -2207,6 +2211,153 @@ class Extractor {
return values;
}

enumTypeAliasConstArrayVariants(
node: ts.TypeAliasDeclaration,
indexedAccessType: ts.IndexedAccessTypeNode,
): EnumValueDefinitionNode[] | null {
let objectType: ts.TypeNode = indexedAccessType.objectType;
while (ts.isParenthesizedTypeNode(objectType)) {
objectType = objectType.type;
}

if (
!ts.isTypeQueryNode(objectType) ||
!ts.isIdentifier(objectType.exprName) ||
indexedAccessType.indexType.kind !== ts.SyntaxKind.NumberKeyword
) {
this.reportUnhandled(
indexedAccessType,
"union",
E.enumTagOnInvalidNode(),
);
return null;
}

const declaration = this.findTopLevelConstDeclaration(
node.getSourceFile(),
objectType.exprName.text,
);
if (declaration == null) {
this.reportUnhandled(
indexedAccessType,
"union",
E.enumTagOnInvalidNode(),
);
return null;
}

const arrayLiteral = this.constAssertionArrayExpression(declaration);
if (arrayLiteral == null) {
this.reportUnhandled(
indexedAccessType,
"union",
E.enumTagOnInvalidNode(),
);
return null;
}

if (arrayLiteral.elements.length === 0) {
this.report(arrayLiteral, E.enumTypeAliasConstArrayEmpty());
return null;
}

const values: EnumValueDefinitionNode[] = [];
const seenValues = new Map<string, ts.StringLiteral>();
for (const element of arrayLiteral.elements) {
if (!ts.isStringLiteral(element)) {
this.reportUnhandled(
element,
"union member",
E.enumVariantNotStringLiteral(),
);
continue;
}

const duplicateValueNode = seenValues.get(element.text);
if (duplicateValueNode != null) {
this.report(element, E.enumVariantDuplicateValue(element.text), [
tsRelated(
duplicateValueNode,
"Previous enum member with this value.",
),
]);
continue;
}

seenValues.set(element.text, element);

const errorMessage = graphQLNameValidationMessage(element.text);
if (errorMessage != null) {
this.report(element, errorMessage);
}

values.push(
this.gql.enumValueDefinition(
node,
this.gql.name(element, element.text),
undefined,
null,
null,
),
);
}

return values;
}

findTopLevelConstDeclaration(
sourceFile: ts.SourceFile,
name: string,
): ts.VariableDeclaration | null {
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) {
continue;
}

if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) {
continue;
}

for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name)) {
continue;
}

if (declaration.name.text === name) {
return declaration;
}
}
}

return null;
}

constAssertionArrayExpression(
declaration: ts.VariableDeclaration,
): ts.ArrayLiteralExpression | null {
if (declaration.initializer == null) {
return null;
}

if (!ts.isAsExpression(declaration.initializer)) {
return null;
}

if (
!ts.isTypeReferenceNode(declaration.initializer.type) ||
!ts.isIdentifier(declaration.initializer.type.typeName) ||
declaration.initializer.type.typeName.text !== "const"
) {
return null;
}

if (!ts.isArrayLiteralExpression(declaration.initializer.expression)) {
return null;
}

return declaration.initializer.expression;
}

collectEnumValues(
node: ts.EnumDeclaration,
): ReadonlyArray<EnumValueDefinitionNode> {
Expand Down
15 changes: 15 additions & 0 deletions src/tests/fixtures/enums/EnumFromConstArrayTypeQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const ALL_SHOW_STATUSES = [
"draft",
"scheduled",
"unlisted",
"published",
] as const;

/** @gqlType */
class Show {
/** @gqlField */
status: ShowStatus;
}

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# enums/EnumFromConstArrayTypeQuery.ts

## Input

```ts title="enums/EnumFromConstArrayTypeQuery.ts"
const ALL_SHOW_STATUSES = [
"draft",
"scheduled",
"unlisted",
"published",
] as const;

/** @gqlType */
class Show {
/** @gqlField */
status: ShowStatus;
}

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
```

## Output

### SDL

```graphql
enum ShowStatus {
draft
published
scheduled
unlisted
}

type Show {
status: ShowStatus
}
```

### TypeScript

```ts
import { GraphQLSchema, GraphQLEnumType, GraphQLObjectType } from "graphql";
export function getSchema(): GraphQLSchema {
const ShowStatusType: GraphQLEnumType = new GraphQLEnumType({
name: "ShowStatus",
values: {
draft: {
value: "draft"
},
published: {
value: "published"
},
scheduled: {
value: "scheduled"
},
unlisted: {
value: "unlisted"
}
}
});
const ShowType: GraphQLObjectType = new GraphQLObjectType({
name: "Show",
fields() {
return {
status: {
name: "status",
type: ShowStatusType
}
};
}
});
return new GraphQLSchema({
types: [ShowStatusType, ShowType]
});
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const ALL_SHOW_STATUSES = ["draft", "draft"] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts

## Input

```ts title="enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts"
const ALL_SHOW_STATUSES = ["draft", "draft"] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
```

## Output

### Error Report

```text
src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts:1:37 - error: Expected `@gqlEnum` enum members to be unique. Found duplicate value `draft`.

1 const ALL_SHOW_STATUSES = ["draft", "draft"] as const;
~~~~~~~

src/tests/fixtures/enums/EnumFromConstArrayTypeQueryDuplicateValues.invalid.ts:1:28
1 const ALL_SHOW_STATUSES = ["draft", "draft"] as const;
~~~~~~~
Previous enum member with this value.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const ALL_SHOW_STATUSES = [] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts

## Input

```ts title="enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts"
const ALL_SHOW_STATUSES = [] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
```

## Output

### Error Report

```text
src/tests/fixtures/enums/EnumFromConstArrayTypeQueryEmpty.invalid.ts:1:27 - error: Expected `@gqlEnum` const array to include at least one string literal value. For example: `const VALUES = ["foo"] as const; type MyEnum = typeof VALUES[number]`.

1 const ALL_SHOW_STATUSES = [] as const;
~~
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const ALL_SHOW_STATUSES = ["draft-status"] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts

## Input

```ts title="enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts"
const ALL_SHOW_STATUSES = ["draft-status"] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
```

## Output

### Error Report

```text
src/tests/fixtures/enums/EnumFromConstArrayTypeQueryInvalidGraphQLName.invalid.ts:1:28 - error: Names must only contain [_a-zA-Z0-9] but "draft-status" does not.

1 const ALL_SHOW_STATUSES = ["draft-status"] as const;
~~~~~~~~~~~~~~
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const ALL_SHOW_STATUSES = ["draft", 42] as const;

/** @gqlEnum */
type ShowStatus = (typeof ALL_SHOW_STATUSES)[number];
Loading