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
58 changes: 51 additions & 7 deletions packages/graphql/src/lib/scalar-mappings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Program, type Scalar } from "@typespec/compiler";
import { type IntrinsicScalarName, type Program, type Scalar } from "@typespec/compiler";
import { $, type Typekit } from "@typespec/compiler/typekit";

/**
Expand Down Expand Up @@ -158,16 +158,28 @@ export function isStdScalar(tk: Typekit, scalar: Scalar): boolean {
}

/**
* Get the GraphQL custom scalar mapping for a scalar via its standard library ancestor.
* TypeSpec std scalar names that map directly to GraphQL built-in scalar types:
* string → String, boolean → Boolean, int32 → Int, float32/float64 → Float.
*
* These must NOT be renamed by the scalar mutation — they're resolved to
* GraphQL builtins at emit time.
*
* @see https://spec.graphql.org/September2025/#sec-Scalars.Built-in-Scalars
*/
const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [
"string", "boolean", "int32", "float32", "float64",
];

/**
* Get the GraphQL scalar mapping for a scalar via its standard library ancestor.
*
* Uses `tk.scalar.getStdBase()` to find the std ancestor (e.g. `int64` for
* `scalar MyInt extends int64`), then looks up the mapping table by name.
* Returns undefined for built-in scalars (string, boolean, etc.)
* and scalars with no mapped ancestor.
* Returns undefined for scalars with no mapped ancestor.
*
* The caller (scalar mutation) uses `isStdScalar` to decide whether to
* rename with `mapping.graphqlName` or keep the user's name. The mapping
* is always useful for metadata like `@specifiedBy`.
* Note: this returns a mapping even for GraphQL builtins like `float32`
* (which inherits a mapping from `numeric`). Use {@link getCustomScalarMapping}
* when you need a mapping that should trigger renaming — it filters out builtins.
*
* @param program The TypeSpec program
* @param scalar The scalar type to map
Expand All @@ -178,9 +190,41 @@ export function getScalarMapping(
program: Program,
scalar: Scalar,
encoding?: string,
): ScalarMapping | undefined {
return getScalarMappingInternal($(program), scalar, encoding);
}

/**
* Get the GraphQL custom scalar mapping for a standard library scalar —
* i.e., a mapping that should trigger renaming.
*
* Returns undefined for:
* - Scalars with no mapped ancestor
* - GraphQL builtins (string, boolean, int32, float32, float64) that should
* NOT be renamed even though they inherit a mapping via the extends chain
* (e.g. float32 → float → numeric → "Numeric")
* - Non-std scalars (user-defined scalars keep their own name)
*
* @param program The TypeSpec program
* @param scalar The scalar type to map (must be a std scalar)
* @returns The scalar mapping or undefined if the scalar shouldn't be renamed
*/
export function getCustomScalarMapping(
program: Program,
scalar: Scalar,
): ScalarMapping | undefined {
const tk = $(program);
if (!isStdScalar(tk, scalar)) return undefined;
if (TSP_SCALARS_TO_GQL_BUILTINS.some((name) => program.checker.isStdType(scalar, name)))
return undefined;
return getScalarMappingInternal(tk, scalar);
}

function getScalarMappingInternal(
tk: Typekit,
scalar: Scalar,
encoding?: string,
): ScalarMapping | undefined {
// getStdBase walks the baseScalar chain and returns the first ancestor
// in the TypeSpec namespace (identity-safe, not name-based).
const stdBase = tk.scalar.getStdBase(scalar);
Expand Down
54 changes: 30 additions & 24 deletions packages/graphql/src/mutation-engine/mutations/scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
type SimpleMutations,
} from "@typespec/mutator-framework";
import { reportDiagnostic } from "../../lib.js";
import { getScalarMapping, isStdScalar } from "../../lib/scalar-mappings.js";
import {
getCustomScalarMapping,
getScalarMapping,
isStdScalar,
} from "../../lib/scalar-mappings.js";
import { getSpecifiedBy, setSpecifiedByUrl } from "../../lib/specified-by.js";
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";

Expand Down Expand Up @@ -53,39 +57,41 @@ export class GraphQLScalarMutation extends SimpleScalarMutation<SimpleMutationOp
const tk = this.engine.$;
const program = tk.program;
const mapping = getScalarMapping(program, this.sourceType);
const isDirectStd = isStdScalar(tk, this.sourceType);

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;
scalar.baseScalar = undefined;
});
} else if (!isDirectStd) {
// 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 },
} else {
const customMapping = getCustomScalarMapping(program, this.sourceType);
if (customMapping) {
// Std library scalar that maps to a custom GraphQL scalar (e.g. int64 → Long)
this.mutationNode.mutate((scalar) => {
scalar.name = customMapping.graphqlName;
scalar.baseScalar = undefined;
});
} else if (!isStdScalar(tk, this.sourceType)) {
// 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 = sanitizedName;
scalar.baseScalar = undefined;
});
}
this.mutationNode.mutate((scalar) => {
scalar.name = sanitizedName;
scalar.baseScalar = undefined;
});
// else: Built-in std scalars (string, boolean, int32, etc.) are left untouched —
// they map to GraphQL built-in types and are resolved at emit time.
}
// Built-in std scalars (string, boolean, int32, etc.) are left untouched —
// they map to GraphQL built-in types and are resolved at emit time.

// Apply @specifiedBy: explicit decorator on source wins, then mapping table
// (mapping may come from an ancestor via the extends chain)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,60 @@ describe("GraphQL Mutation Engine - Scalars", () => {
expect(mutation.mutatedType.name).toBe("ID");
});

it("does not rename builtin std scalars even when they inherit a mapping", async () => {
// float32 inherits a mapping via float → numeric → "Numeric", but it's a
// GraphQL builtin (maps to Float) and must never be renamed.
const { M } = await tester.compile(
t.code`model ${t.model("M")} { value: float32; }`,
);

const engine = createTestEngine(tester.program);
const float32Scalar = M.properties.get("value")!.type;
expect(float32Scalar.kind).toBe("Scalar");
const mutation = engine.mutateScalar(float32Scalar as any);

expect(mutation.mutatedType.name).toBe("float32");
});

it("does not rename float64 builtin scalar", async () => {
const { M } = await tester.compile(
t.code`model ${t.model("M")} { value: float64; }`,
);

const engine = createTestEngine(tester.program);
const float64Scalar = M.properties.get("value")!.type;
expect(float64Scalar.kind).toBe("Scalar");
const mutation = engine.mutateScalar(float64Scalar as any);

expect(mutation.mutatedType.name).toBe("float64");
});

it("does not rename int32 builtin scalar", async () => {
const { M } = await tester.compile(
t.code`model ${t.model("M")} { count: int32; }`,
);

const engine = createTestEngine(tester.program);
const int32Scalar = M.properties.get("count")!.type;
expect(int32Scalar.kind).toBe("Scalar");
const mutation = engine.mutateScalar(int32Scalar as any);

expect(mutation.mutatedType.name).toBe("int32");
});

it("still renames mapped non-builtin std scalars like int64", async () => {
const { M } = await tester.compile(
t.code`model ${t.model("M")} { big: int64; }`,
);

const engine = createTestEngine(tester.program);
const int64Scalar = M.properties.get("big")!.type;
expect(int64Scalar.kind).toBe("Scalar");
const mutation = engine.mutateScalar(int64Scalar as any);

expect(mutation.mutatedType.name).toBe("Long");
});

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;`,
Expand Down