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, getDoc, 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 = getDoc(program, 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, getDoc, 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 = getDoc(program, 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={getDoc(program, param)}
deprecated={
getDeprecationDetails(program, param)?.message
}
>
{paramTypeInfo.isList ? (
<gql.InputValue.List
nonNull={paramTypeInfo.isNonNull}
/>
) : undefined}
</gql.InputValue>
)}
</GraphQLTypeExpression>
))}
</gql.Field>
)}
</GraphQLTypeExpression>
);
}
172 changes: 172 additions & 0 deletions packages/graphql/src/components/fields/type-expression.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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 { useGraphQLSchema } from "../../context/index.js";
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 { modelVariants } = useGraphQLSchema();

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

// Input fields are non-null unless nullable; optionality is expressed via
// default values. Output fields are non-null unless optional or nullable.
const isNonNull = nullable ? false : props.isInput || !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)) {
// Both input and output variants share the same source model identity,
// so we use name-based lookup to determine if both variants exist.
const hasOutputVariant = modelVariants.outputModels.has(type.name);
const hasInputVariant = modelVariants.inputModels.has(type.name);

if (props.isInput && hasOutputVariant && hasInputVariant) {
return `${type.name}Input`;
}
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.`,
);
}
}
Loading