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
11 changes: 7 additions & 4 deletions packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@
"node": ">=20.0.0"
},
"dependencies": {
"@alloy-js/core": "^0.11.0",
"@alloy-js/typescript": "^0.11.0",
"@alloy-js/core": "^0.22.0",
"@alloy-js/graphql": "^0.1.0",
"@alloy-js/typescript": "^0.22.0",
"change-case": "^5.4.4",
"graphql": "^16.9.0"
},
"scripts": {
"clean": "rimraf ./dist ./temp",
"build": "tsc -p .",
"watch": "tsc --watch",
"build": "alloy build",
"watch": "alloy build --watch",
"test": "vitest run",
"test:watch": "vitest -w",
"lint": "eslint . --max-warnings=0",
Expand All @@ -56,6 +57,8 @@
"@typespec/mutator-framework": "workspace:~"
},
"devDependencies": {
"@alloy-js/cli": "^0.22.0",
"@alloy-js/rollup-plugin": "^0.1.0",
"@types/node": "~22.13.13",
"@typespec/compiler": "workspace:~",
"@typespec/emitter-framework": "workspace:~",
Expand Down
62 changes: 62 additions & 0 deletions packages/graphql/src/components/fields/field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { type ModelProperty, getDeprecationDetails } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";
import { isNullable, hasNullableElements } from "../../lib/nullable.js";
import { GraphQLTypeExpression } from "./type-expression.js";

export interface FieldProps {
/** The model property to render as a field */
property: ModelProperty;
/** Whether this field is in an input type context */
isInput: boolean;
}

export function Field(props: FieldProps) {
const { $, program } = useTsp();

const doc = $.type.getDoc(props.property);
const deprecation = getDeprecationDetails(program, props.property);

return (
<GraphQLTypeExpression
type={props.property.type}
isOptional={props.property.optional}
isInput={props.isInput}
isNullable={isNullable(props.property)}
hasNullableElements={hasNullableElements(props.property)}
targetType={props.property}
>
{(typeInfo) => {
if (props.isInput) {
return (
<gql.InputField
name={props.property.name}
type={typeInfo.typeName}
nonNull={typeInfo.isList ? typeInfo.itemNonNull : typeInfo.isNonNull}
description={doc}
deprecated={deprecation ? deprecation.message : undefined}
>
{typeInfo.isList ? (
<gql.InputField.List nonNull={typeInfo.isNonNull} />
) : undefined}
</gql.InputField>
);
}

return (
<gql.Field
name={props.property.name}
type={typeInfo.typeName}
nonNull={typeInfo.isList ? typeInfo.itemNonNull : typeInfo.isNonNull}
description={doc}
deprecated={deprecation ? deprecation.message : undefined}
>
{typeInfo.isList ? (
<gql.Field.List nonNull={typeInfo.isNonNull} />
) : undefined}
</gql.Field>
);
}}
</GraphQLTypeExpression>
);
}
7 changes: 7 additions & 0 deletions packages/graphql/src/components/fields/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { Field, type FieldProps } from "./field.js";
export { OperationField, type OperationFieldProps } from "./operation-field.js";
export {
GraphQLTypeExpression,
type GraphQLTypeExpressionProps,
type GraphQLTypeInfo,
} from "./type-expression.js";
80 changes: 80 additions & 0 deletions packages/graphql/src/components/fields/operation-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { type Operation, getDeprecationDetails } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";
import { isNullable, hasNullableElements } from "../../lib/nullable.js";
import { GraphQLTypeExpression } from "./type-expression.js";

export interface OperationFieldProps {
/** The operation to render as a field */
operation: Operation;
}

/**
* Renders an operation as a field with arguments, used for @operationFields.
*/
export function OperationField(props: OperationFieldProps) {
const { $, program } = useTsp();
const params = Array.from(props.operation.parameters.properties.values());
const doc = $.type.getDoc(props.operation);
const deprecation = getDeprecationDetails(program, props.operation);

return (
<GraphQLTypeExpression
type={props.operation.returnType}
isOptional={false}
isInput={false}
isNullable={isNullable(props.operation)}
targetType={props.operation}
>
{(returnTypeInfo) => (
<gql.Field
name={props.operation.name}
type={returnTypeInfo.typeName}
nonNull={
returnTypeInfo.isList
? returnTypeInfo.itemNonNull
: returnTypeInfo.isNonNull
}
description={doc}
deprecated={deprecation ? deprecation.message : undefined}
>
{returnTypeInfo.isList ? (
<gql.Field.List nonNull={returnTypeInfo.isNonNull} />
) : undefined}
{params.map((param) => (
<GraphQLTypeExpression
type={param.type}
isOptional={param.optional}
isInput={true}
isNullable={isNullable(param)}
hasNullableElements={hasNullableElements(param)}
targetType={param}
>
{(paramTypeInfo) => (
<gql.InputValue
name={param.name}
type={paramTypeInfo.typeName}
nonNull={
paramTypeInfo.isList
? paramTypeInfo.itemNonNull
: paramTypeInfo.isNonNull
}
description={$.type.getDoc(param)}
deprecated={
getDeprecationDetails(program, param)?.message
}
>
{paramTypeInfo.isList ? (
<gql.InputValue.List
nonNull={paramTypeInfo.isNonNull}
/>
) : undefined}
</gql.InputValue>
)}
</GraphQLTypeExpression>
))}
</gql.Field>
)}
</GraphQLTypeExpression>
);
}
166 changes: 166 additions & 0 deletions packages/graphql/src/components/fields/type-expression.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {
type Type,
type Scalar,
type ModelProperty,
getEncode,
isUnknownType,
} from "@typespec/compiler";
import { type Children } from "@alloy-js/core";
import { useTsp } from "@typespec/emitter-framework";
import { isNullable } from "../../lib/nullable.js";
import { unwrapNullableUnion, getUnionName } from "../../lib/type-utils.js";
import { getGraphQLBuiltinName, getScalarMapping } from "../../lib/scalar-mappings.js";

/**
* Information about a resolved GraphQL type
*/
export interface GraphQLTypeInfo {
/** The base type name (without wrappers) */
typeName: string;
/** Whether this is a list type */
isList: boolean;
/** Whether the field itself is non-null */
isNonNull: boolean;
/** Whether list items are non-null (only meaningful if isList is true) */
itemNonNull: boolean;
}

export interface GraphQLTypeExpressionProps {
type: Type;
isOptional: boolean;
/** Whether this type is in an input position (operation parameter or input model field) */
isInput: boolean;
/** Whether this type was marked nullable (from property-level tracking) */
isNullable?: boolean;
/** Whether this property's array elements were originally T | null (from property-level tracking) */
hasNullableElements?: boolean;
/** The property or parameter that contains the type (for @encode checking) */
targetType?: Type;
children: (typeInfo: GraphQLTypeInfo) => Children;
}

export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) {
const { $, program } = useTsp();

const nullable = props.isNullable || isNullable(props.type);

// Fields are non-null unless nullable or optional.
// In GraphQL, optional fields are represented as nullable (per spec:
// "nullability directly determines whether a field is required").
// This also allows circular references in input types (e.g., Author.friend?: Author).
const isNonNull = !nullable && !props.isOptional;

// Unwrap T | null unions the mutation engine didn't process (e.g., array
// elements, operation parameters that arrive here still wrapped).
if ($.union.is(props.type)) {
const innerType = unwrapNullableUnion(props.type);
if (innerType) {
return (
<GraphQLTypeExpression
type={innerType}
isOptional={props.isOptional}
isInput={props.isInput}
isNullable={true}
targetType={props.targetType}
>
{(innerInfo) =>
props.children({
...innerInfo,
isNonNull: false,
})
}
</GraphQLTypeExpression>
);
}
}

if ($.array.is(props.type)) {
const elementType = $.array.getElementType(props.type);
// Element nullability: from mutation engine property-level tracking, from the
// element type's own state map, or from an inline T | null union still present.
const elementIsNullable =
props.hasNullableElements ||
isNullable(elementType) ||
($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined);

return (
<GraphQLTypeExpression
type={elementType}
isOptional={false}
isInput={props.isInput}
isNullable={elementIsNullable}
targetType={props.targetType}
>
{(elementInfo) =>
props.children({
typeName: elementInfo.typeName,
isList: true,
isNonNull,
itemNonNull: !elementIsNullable,
})
}
</GraphQLTypeExpression>
);
}

const typeName = resolveBaseTypeName();

return props.children({
typeName,
isList: false,
isNonNull,
itemNonNull: false,
});

function resolveBaseTypeName(): string {
const type = props.type;

if (isUnknownType(type)) {
return "Unknown";
}

if ($.scalar.is(type)) {
const builtinName = getGraphQLBuiltinName(program, type);
if (builtinName) return builtinName;

// Std scalars with encoding-specific mappings (e.g., bytes + base64 -> Bytes)
if (program.checker.isStdType(type)) {
if (
props.targetType &&
($.scalar.is(props.targetType) || props.targetType.kind === "ModelProperty")
) {
const encodeData = getEncode(
program,
props.targetType as Scalar | ModelProperty,
);
const mapping = getScalarMapping(program, type, encodeData?.encoding);
if (mapping) return mapping.graphqlName;
}

const mapping = getScalarMapping(program, type);
if (mapping) return mapping.graphqlName;
}

return type.name;
}

if ($.model.is(type)) {
// The mutation engine handles input/output naming - input models are
// already suffixed with "Input" when they need to be distinguished.
return type.name;
}

if ($.enum.is(type)) {
return type.name;
}

if ($.union.is(type)) {
return getUnionName(type, program);
}

throw new Error(
`Unexpected type kind "${type.kind}" in resolveBaseTypeName. ` +
`This is a bug in the GraphQL emitter.`,
);
}
}
43 changes: 43 additions & 0 deletions packages/graphql/src/context/graphql-schema-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core";
import type { Namespace } from "@typespec/compiler";

/**
* A self-contained type world produced by the mutation pipeline.
* The namespace contains all mutated types for the schema.
*/
export interface TypeGraph {
readonly globalNamespace: Namespace;
}

/**
* Context value containing the mutated type graph for schema generation.
*
* For access to the TypeSpec program and typekit, use `useTsp()` from
* `@typespec/emitter-framework` instead.
*/
export interface GraphQLSchemaContextValue {
typeGraph: TypeGraph;
}

/**
* Context provider for GraphQL schema generation
*/
export const GraphQLSchemaContext: ComponentContext<GraphQLSchemaContextValue> =
createNamedContext<GraphQLSchemaContextValue>("GraphQLSchema");

/**
* Hook to access GraphQL schema context
* @returns The GraphQL schema context value
* @throws Error if used outside of GraphQLSchemaContext.Provider
*/
export function useGraphQLSchema(): GraphQLSchemaContextValue {
const context = useContext(GraphQLSchemaContext);

if (!context) {
throw new Error(
"useGraphQLSchema must be used within GraphQLSchemaContext.Provider.",
);
}

return context;
}
6 changes: 6 additions & 0 deletions packages/graphql/src/context/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
GraphQLSchemaContext,
useGraphQLSchema,
type GraphQLSchemaContextValue,
type TypeGraph,
} from "./graphql-schema-context.js";
Loading