Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/graphql/lib/main.tsp
Original file line number Diff line number Diff line change
@@ -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";
17 changes: 17 additions & 0 deletions packages/graphql/lib/scalars.tsp
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ export const libDef = {
default: "Union has no non-null variants. A GraphQL union must contain at least one member type.",
},
},
"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<GraphQLEmitterOptions>,
Expand Down
45 changes: 43 additions & 2 deletions packages/graphql/src/mutation-engine/mutations/scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SimpleMutationOptions> {
constructor(
Expand All @@ -28,7 +55,13 @@ export class GraphQLScalarMutation extends SimpleScalarMutation<SimpleMutationOp
const mapping = getScalarMapping(program, this.sourceType);
const isDirectStd = isStdScalar(tk, this.sourceType);

if (mapping && isDirectStd) {
if (isGraphQLIdScalar(this.sourceType)) {
// GraphQL library scalar ID (or extends it) → built-in GraphQL ID type
this.mutationNode.mutate((scalar) => {
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;
Expand All @@ -38,8 +71,16 @@ export class GraphQLScalarMutation extends SimpleScalarMutation<SimpleMutationOp
// User-defined custom scalar — sanitize name, strip extends.
// May still have a mapping via extends chain (e.g. scalar MyInt extends int64),
// which is used for @specifiedBy below but not for renaming.
const sanitizedName = sanitizeNameForGraphQL(this.sourceType.name);
if (GRAPHQL_BUILTIN_SCALARS.has(sanitizedName)) {
reportDiagnostic(program, {
code: "graphql-builtin-scalar-collision",
target: this.sourceType,
format: { name: this.sourceType.name, builtinName: sanitizedName },
});
}
this.mutationNode.mutate((scalar) => {
scalar.name = sanitizeNameForGraphQL(scalar.name);
scalar.name = sanitizedName;
scalar.baseScalar = undefined;
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,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", () => {
Expand Down