diff --git a/packages/graphql/lib/main.tsp b/packages/graphql/lib/main.tsp index ba0133181fe..4b6c5d69627 100644 --- a/packages/graphql/lib/main.tsp +++ b/packages/graphql/lib/main.tsp @@ -1,5 +1,6 @@ import "./interface.tsp"; import "./operation-fields.tsp"; import "./operation-kind.tsp"; +import "./scalars.tsp"; import "./schema.tsp"; import "./specified-by.tsp"; diff --git a/packages/graphql/lib/scalars.tsp b/packages/graphql/lib/scalars.tsp new file mode 100644 index 00000000000..26ec0808e48 --- /dev/null +++ b/packages/graphql/lib/scalars.tsp @@ -0,0 +1,17 @@ +namespace TypeSpec.GraphQL; + +/** + * Represents a GraphQL ID scalar — a unique identifier serialized as a string. + * + * @see https://spec.graphql.org/September2025/#sec-ID + * + * @example + * + * ```typespec + * model User { + * id: GraphQL.ID; + * name: string; + * } + * ``` + */ +scalar ID extends string; diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 4e8c00465f2..23e32a7590c 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -24,10 +24,6 @@ "typespec": "./lib/main.tsp", "types": "./dist/src/index.d.ts", "default": "./dist/src/index.js" - }, - "./testing": { - "types": "./dist/src/testing/index.d.ts", - "default": "./dist/src/testing/index.js" } }, "engines": { diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index e87142f5185..92440ed08a7 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -148,6 +148,12 @@ export const libDef = { default: paramMessage`Union variant type "${"type"}" appears multiple times after flattening nested unions. Duplicate removed.`, }, }, + "graphql-builtin-scalar-collision": { + severity: "warning", + messages: { + default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/graphql/src/mutation-engine/mutations/scalar.ts b/packages/graphql/src/mutation-engine/mutations/scalar.ts index dc2fa974923..57661b8fc2d 100644 --- a/packages/graphql/src/mutation-engine/mutations/scalar.ts +++ b/packages/graphql/src/mutation-engine/mutations/scalar.ts @@ -6,10 +6,37 @@ import { type SimpleMutationOptions, type SimpleMutations, } from "@typespec/mutator-framework"; +import { reportDiagnostic } from "../../lib.js"; import { getScalarMapping, isStdScalar } from "../../lib/scalar-mappings.js"; import { getSpecifiedBy, setSpecifiedByUrl } from "../../lib/specified-by.js"; import { sanitizeNameForGraphQL } from "../../lib/type-utils.js"; +/** + * GraphQL built-in scalar type names. + * @see https://spec.graphql.org/September2025/#sec-Scalars.Built-in-Scalars + */ +const GRAPHQL_BUILTIN_SCALARS = new Set(["String", "Int", "Float", "Boolean", "ID"]); + +/** + * Check whether a scalar is the GraphQL library's `ID` scalar, or extends it. + * Walks the baseScalar chain looking for a scalar named "ID" in the + * TypeSpec.GraphQL namespace. + */ +function isGraphQLIdScalar(scalar: Scalar): boolean { + let current: Scalar | undefined = scalar; + while (current) { + if ( + current.name === "ID" && + current.namespace?.name === "GraphQL" && + current.namespace?.namespace?.name === "TypeSpec" + ) { + return true; + } + current = current.baseScalar; + } + return false; +} + /** GraphQL-specific Scalar mutation */ export class GraphQLScalarMutation extends SimpleScalarMutation { constructor( @@ -28,7 +55,13 @@ export class GraphQLScalarMutation extends SimpleScalarMutation { + scalar.name = "ID"; + scalar.baseScalar = undefined; + }); + } else if (mapping && isDirectStd) { // Std library scalar that maps to a custom GraphQL scalar (e.g. int64 → Long) this.mutationNode.mutate((scalar) => { scalar.name = mapping.graphqlName; @@ -38,8 +71,16 @@ export class GraphQLScalarMutation extends SimpleScalarMutation { - scalar.name = sanitizeNameForGraphQL(scalar.name); + scalar.name = sanitizedName; scalar.baseScalar = undefined; }); } diff --git a/packages/graphql/src/testing/index.ts b/packages/graphql/src/testing/index.ts deleted file mode 100644 index 8f6c2137756..00000000000 --- a/packages/graphql/src/testing/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { TypeSpecTestLibrary } from "@typespec/compiler/testing"; -import { createTestLibrary, findTestPackageRoot } from "@typespec/compiler/testing"; - -export const GraphQLTestLibrary: TypeSpecTestLibrary = createTestLibrary({ - name: "@typespec/graphql", - packageRoot: await findTestPackageRoot(import.meta.url), -}); diff --git a/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts b/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts index a5fd4565e42..ff2859e7a3d 100644 --- a/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts +++ b/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts @@ -301,6 +301,46 @@ describe("GraphQL Mutation Engine - Scalars", () => { ); }); + it("maps scalar extending GraphQL.ID to built-in ID type", async () => { + const { MyId } = await tester.compile( + t.code`scalar ${t.scalar("MyId")} extends GraphQL.ID;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(MyId); + + expect(mutation.mutatedType.name).toBe("ID"); + }); + + it("maps multi-hop extends chain through GraphQL.ID to built-in ID type", async () => { + const { SubId } = await tester.compile( + t.code` + scalar MyId extends GraphQL.ID; + scalar ${t.scalar("SubId")} extends MyId; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(SubId); + + expect(mutation.mutatedType.name).toBe("ID"); + }); + + it("warns when user-defined scalar collides with GraphQL built-in name", async () => { + const { Float } = await tester.compile( + t.code`scalar ${t.scalar("Float")} extends string;`, + ); + + const engine = createTestEngine(tester.program); + engine.mutateScalar(Float); + + const warnings = tester.program.diagnostics.filter( + (d) => d.code === "@typespec/graphql/graphql-builtin-scalar-collision", + ); + expect(warnings.length).toBe(1); + expect(warnings[0].message).toContain("Float"); + }); + }); describe("GraphQL Mutation Engine - Edge Cases", () => { diff --git a/packages/graphql/test/test-host.ts b/packages/graphql/test/test-host.ts index 5cb043b2813..d921866f9aa 100644 --- a/packages/graphql/test/test-host.ts +++ b/packages/graphql/test/test-host.ts @@ -1,29 +1,20 @@ -import { type Diagnostic, type Program, resolvePath, type Type } from "@typespec/compiler"; -import { - createTester, - createTestHost, - createTestWrapper, - expectDiagnosticEmpty, - resolveVirtualPath, -} from "@typespec/compiler/testing"; +import { type Diagnostic, resolvePath } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; import { ok } from "assert"; import type { GraphQLSchema } from "graphql"; import { buildSchema } from "graphql"; import { expect } from "vitest"; import type { GraphQLEmitterOptions } from "../src/lib.js"; -import { GraphQLTestLibrary } from "../src/testing/index.js"; + +const outputFileName = "schema.graphql"; export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { - libraries: [GraphQLTestLibrary.name], + libraries: ["@typespec/graphql"], }) .importLibraries() .using("TypeSpec.GraphQL"); -export async function createGraphQLTestHost() { - return createTestHost({ - libraries: [GraphQLTestLibrary], - }); -} +export const EmitterTester = Tester.emit("@typespec/graphql"); export interface GraphQLTestResult { readonly graphQLSchema?: GraphQLSchema; @@ -31,55 +22,20 @@ export interface GraphQLTestResult { readonly diagnostics: readonly Diagnostic[]; } -export async function createGraphQLTestRunner() { - const host = await createGraphQLTestHost(); - - return createTestWrapper(host, { - autoUsings: ["TypeSpec.GraphQL"], - compilerOptions: { - noEmit: false, - emit: ["@typespec/graphql"], - }, - }); -} - -export async function diagnose(code: string): Promise { - const runner = await createGraphQLTestRunner(); - return runner.diagnose(code); -} - -export async function compileAndDiagnose>( - code: string, -): Promise<[Program, T, readonly Diagnostic[]]> { - const runner = await createGraphQLTestRunner(); - const [testTypes, diagnostics] = await runner.compileAndDiagnose(code); - return [runner.program, testTypes as T, diagnostics]; -} - export async function emitWithDiagnostics( code: string, options: GraphQLEmitterOptions = {}, ): Promise { - const runner = await createGraphQLTestRunner(); - const outputFile = resolveVirtualPath("schema.graphql"); - const compilerOptions = { ...options, "output-file": outputFile }; - const diagnostics = await runner.diagnose(code, { - noEmit: false, - emit: ["@typespec/graphql"], - options: { - "@typespec/graphql": compilerOptions, + const outputFile = `{emitter-output-dir}/${outputFileName}`; + const [result, diagnostics] = await EmitterTester.compileAndDiagnose(code, { + compilerOptions: { + options: { + "@typespec/graphql": { ...options, "output-file": outputFile }, + }, }, }); - /** - * There doesn't appear to be a good way to hook into the emit process and get the GraphQLSchema - * that's produced by the emitter. So we're going to read the file that was emitted and parse it. - * - * This is the same way it's done in @typespec/openapi3: - * https://github.com/microsoft/typespec/blame/1cf8601d0f65f707926d58d56566fb0cb4d4f4ff/packages/openapi3/test/test-host.ts#L105 - */ - - const content = runner.fs.get(outputFile); + const content = result.outputs[outputFileName]; const schema = content ? buildSchema(content, { assumeValidSDL: true,