Skip to content

Commit 6e682f0

Browse files
Add mutation engine for TypeSpec-to-GraphQL type transformation (#62)
## Summary Adds utility functions for transforming TypeSpec names into valid GraphQL identifiers. These utilities form the foundation for name handling throughout the GraphQL emitter. ## Changes - **`src/lib/type-utils.ts`** - Core utility functions for GraphQL name transformations - **`test/lib/type-utils.test.ts`** - Unit tests for `sanitizeNameForGraphQL` ## Utilities Added | Function | Purpose | |----------|---------| | `sanitizeNameForGraphQL` | Sanitize names to be valid GraphQL identifiers | | `toTypeName` | Convert to PascalCase for type names | | `toFieldName` | Convert to camelCase for field names | | `toEnumMemberName` | Convert to CONSTANT_CASE for enum members | | `getUnionName` | Generate names for anonymous unions | | `getTemplatedModelName` | Generate names for templated models (e.g., `ListOfString`) | | `isArray`, `isRecordType` | Type guards for array/record models | | `unwrapModel`, `unwrapType` | Extract element types from arrays | | `isTrueModel` | Check if a model should emit as GraphQL object type | | `getGraphQLDoc` | Extract doc comments for GraphQL descriptions | * Add mutation engine for TypeSpec-to-GraphQL type transformation Introduce a mutation engine that transforms TypeSpec types into GraphQL-compatible forms using the mutator framework. Includes mutations for enums, models, scalars, unions, and operations. Also includes package hygiene: add tspMain, update node engine to >=20, add api-extractor.json, CHANGELOG.md, fix testing casing, and clean up dead code. * cleanup * Add input/output type context splitting to mutation engine Operations automatically propagate input context to parameters and output context to return types via GraphQLMutationOptions. The framework's cache and options propagation handle nested types, so the same source model produces separate input and output mutations without any custom type-graph walking. * Update packages/graphql/src/lib/scalar-mappings.ts Co-authored-by: Steve Rice <srice@pinterest.com> * Update packages/graphql/src/lib/scalar-mappings.ts Co-authored-by: Steve Rice <srice@pinterest.com> * Update packages/graphql/src/lib/scalar-mappings.ts Co-authored-by: Steve Rice <srice@pinterest.com> * Update packages/graphql/src/lib/scalar-mappings.ts Co-authored-by: Steve Rice <srice@pinterest.com> * Update packages/graphql/src/lib/scalar-mappings.ts Co-authored-by: Steve Rice <srice@pinterest.com> * Address PR comments * Address additional comments * Remove mutateModel variant * Update to use typekit functions * handle scalar extends based on PR feedback * Handle input unions -> oneOf mutation * Add specificationUrls for PlainDate, PlainTime, and BigDecimal scalars Use scalars.graphql.org hosted specs per review feedback: - PlainDate → andimarek/local-date - PlainTime → apollographql/localtime-v0.1 - BigDecimal (decimal, decimal128) → chillicream/decimal * Update packages/graphql/lib/specified-by.tsp Co-authored-by: Steve Rice <srice@pinterest.com> * Address feedback, round out nullability handling * Carry nullability info in state map for nullable wrapper unions --------- Co-authored-by: Steve Rice <srice@pinterest.com>
1 parent c204b15 commit 6e682f0

30 files changed

Lines changed: 2491 additions & 27 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
3+
"extends": "../../api-extractor.base.json"
4+
}

packages/graphql/lib/main.tsp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ import "./interface.tsp";
22
import "./operation-fields.tsp";
33
import "./operation-kind.tsp";
44
import "./schema.tsp";
5+
import "./specified-by.tsp";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import "../dist/src/lib/specified-by.js";
2+
3+
using TypeSpec.Reflection;
4+
5+
namespace TypeSpec.GraphQL;
6+
7+
/**
8+
* Provide a specification URL for a custom GraphQL scalar type.
9+
* This maps to the `@specifiedBy` directive in the emitted GraphQL schema.
10+
*
11+
* @param url URL to the scalar type specification
12+
* @example
13+
*
14+
* ```typespec
15+
* @specifiedBy("https://scalars.graphql.org/jakobmerrild/long.html")
16+
* scalar Long extends int64;
17+
* ```
18+
*/
19+
extern dec specifiedBy(target: Scalar, url: valueof url);

packages/graphql/package.json

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"typespec"
1818
],
1919
"type": "module",
20+
"tspMain": "lib/main.tsp",
2021
"main": "dist/src/index.js",
2122
"exports": {
2223
".": {
@@ -30,14 +31,12 @@
3031
}
3132
},
3233
"engines": {
33-
"node": ">=18.0.0"
34-
},
35-
"graphql": {
36-
"documents": "test/**/*.{js,ts}"
34+
"node": ">=20.0.0"
3735
},
3836
"dependencies": {
3937
"@alloy-js/core": "^0.11.0",
4038
"@alloy-js/typescript": "^0.11.0",
39+
"change-case": "^5.4.4",
4140
"graphql": "^16.9.0"
4241
},
4342
"scripts": {
@@ -46,8 +45,8 @@
4645
"watch": "tsc --watch",
4746
"test": "vitest run",
4847
"test:watch": "vitest -w",
49-
"lint": "eslint src/ test/ --report-unused-disable-directives --max-warnings=0",
50-
"lint:fix": "eslint . --report-unused-disable-directives --fix"
48+
"lint": "eslint . --max-warnings=0",
49+
"lint:fix": "eslint . --fix"
5150
},
5251
"files": [
5352
"lib/*.tsp",
@@ -56,11 +55,16 @@
5655
],
5756
"peerDependencies": {
5857
"@typespec/compiler": "workspace:~",
58+
"@typespec/emitter-framework": "workspace:~",
5959
"@typespec/http": "workspace:~",
60-
"@typespec/emitter-framework": "^0.5.0"
60+
"@typespec/mutator-framework": "workspace:~"
6161
},
6262
"devDependencies": {
6363
"@types/node": "~22.13.13",
64+
"@typespec/compiler": "workspace:~",
65+
"@typespec/emitter-framework": "workspace:~",
66+
"@typespec/http": "workspace:~",
67+
"@typespec/mutator-framework": "workspace:~",
6468
"rimraf": "~6.0.1",
6569
"source-map-support": "~0.5.21",
6670
"typescript": "~5.8.2",

packages/graphql/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export { $onEmit } from "./emitter.js";
22
export { $lib } from "./lib.js";
33
export { $decorators } from "./tsp-index.js";
4+
5+
export { createGraphQLMutationEngine } from "./mutation-engine/index.js";

packages/graphql/src/lib.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,24 @@ export const libDef = {
136136
default: paramMessage`Property \`${"property"}\` is incompatible with \`${"interface"}\`.`,
137137
},
138138
},
139+
"unrecognized-union": {
140+
severity: "error",
141+
messages: {
142+
default: "Unrecognized union construction. Union must be named, a return type, a model property, or an alias.",
143+
},
144+
},
145+
"duplicate-union-variant": {
146+
severity: "warning",
147+
messages: {
148+
default: paramMessage`Union variant type "${"type"}" appears multiple times after flattening nested unions. Duplicate removed.`,
149+
},
150+
},
151+
"empty-union": {
152+
severity: "error",
153+
messages: {
154+
default: "Union has no non-null variants. A GraphQL union must contain at least one member type.",
155+
},
156+
},
139157
},
140158
emitter: {
141159
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,
@@ -149,6 +167,12 @@ export const libDef = {
149167
compose: { description: "State for the @compose decorator." },
150168
interface: { description: "State for the @Interface decorator." },
151169
schema: { description: "State for the @schema decorator." },
170+
specifiedBy: { description: "State for the @specifiedBy decorator." },
171+
oneOf: { description: "State for tracking @oneOf input objects created from input unions." },
172+
nullable: {
173+
description:
174+
"State for tracking types that were determined to be nullable from null-variant stripping.",
175+
},
152176
},
153177
} as const;
154178

packages/graphql/src/lib/interface.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { useStateMap, useStateSet } from "@typespec/compiler/utils";
1212
import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
1313
import type { Tagged } from "../types.d.ts";
14+
import { propertiesEqual } from "./utils.js";
1415

1516
// This will set the namespace for decorators implemented in this file
1617
export const namespace = NAMESPACE;
@@ -75,13 +76,6 @@ function validateNoCircularImplementation(
7576
return valid;
7677
}
7778

78-
function propertiesEqual(prop1: ModelProperty, prop2: ModelProperty): boolean {
79-
// TODO is there some canonical way to do this?
80-
return (
81-
prop1.name === prop2.name && prop1.type === prop2.type && prop1.optional === prop2.optional
82-
);
83-
}
84-
8579
function validateImplementsInterfaceProperties(
8680
context: DecoratorContext,
8781
modelProperties: Map<string, ModelProperty>,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Program, Type } from "@typespec/compiler";
2+
import { useStateSet } from "@typespec/compiler/utils";
3+
import { GraphQLKeys } from "../lib.js";
4+
5+
const [getNullableState, setNullableState] = useStateSet<Type>(GraphQLKeys.nullable);
6+
7+
/**
8+
* Check if a type has been marked as nullable due to null-variant stripping.
9+
* For example, `Cat | Dog | null` becomes `union CatDog` marked as nullable.
10+
*/
11+
export function isNullable(program: Program, type: Type): boolean {
12+
return getNullableState(program, type);
13+
}
14+
15+
/**
16+
* Mark a type as nullable. Called by the mutation engine when null variants
17+
* are stripped from a union during processing.
18+
*/
19+
export function setNullable(program: Program, type: Type): void {
20+
setNullableState(program, type);
21+
}

packages/graphql/src/lib/one-of.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Model, Program } from "@typespec/compiler";
2+
import { useStateSet } from "@typespec/compiler/utils";
3+
import { GraphQLKeys } from "../lib.js";
4+
5+
const [getOneOfState, setOneOfState] = useStateSet<Model>(GraphQLKeys.oneOf);
6+
7+
/**
8+
* Check if a model has been marked as a @oneOf input object.
9+
* These are synthetic models created by the union mutation when a union
10+
* is used in input context — GraphQL unions are output-only, so input
11+
* unions become @oneOf input objects.
12+
*/
13+
export function isOneOf(program: Program, model: Model): boolean {
14+
return getOneOfState(program, model);
15+
}
16+
17+
/**
18+
* Mark a model as a @oneOf input object.
19+
*/
20+
export function setOneOf(program: Program, model: Model): void {
21+
setOneOfState(program, model);
22+
}

packages/graphql/src/lib/operation-fields.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ import {
77
type Operation,
88
type Program,
99
} from "@typespec/compiler";
10-
11-
// import { createTypeRelationChecker } from "../../../compiler/dist/src/core/type-relation-checker.js";
12-
1310
import { useStateMap } from "@typespec/compiler/utils";
1411
import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
1512
import { operationsEqual } from "./utils.js";

0 commit comments

Comments
 (0)