Skip to content

Commit 8b51ca4

Browse files
committed
Add Alloy infrastructure, context system, and field components
Introduce the foundation for the component-based GraphQL emitter: - Build/config: Add @alloy-js/core, @alloy-js/graphql dependencies, configure JSX transpilation (tsconfig, vitest) - Context system: GraphQLSchemaContext with ClassifiedTypes, ModelVariants, and ScalarVariant interfaces - Field components: Field, OperationField, and GraphQLTypeExpression for rendering model properties and operations as GraphQL SDL - Type resolution: GraphQLTypeExpression handles scalars, models, enums, unions, arrays, and nullability using an isInput prop to distinguish input vs output context - Scalar mappings: Add getGraphQLBuiltinName() for built-in scalar identity checks
1 parent 3f77e30 commit 8b51ca4

10 files changed

Lines changed: 473 additions & 9 deletions

File tree

packages/graphql/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,16 @@
3030
"node": ">=20.0.0"
3131
},
3232
"dependencies": {
33-
"@alloy-js/core": "^0.11.0",
34-
"@alloy-js/typescript": "^0.11.0",
33+
"@alloy-js/core": "^0.22.0",
34+
"@alloy-js/graphql": "^0.1.0",
35+
"@alloy-js/typescript": "^0.22.0",
3536
"change-case": "^5.4.4",
3637
"graphql": "^16.9.0"
3738
},
3839
"scripts": {
3940
"clean": "rimraf ./dist ./temp",
40-
"build": "tsc -p .",
41-
"watch": "tsc --watch",
41+
"build": "alloy build",
42+
"watch": "alloy build --watch",
4243
"test": "vitest run",
4344
"test:watch": "vitest -w",
4445
"lint": "eslint . --max-warnings=0",
@@ -56,6 +57,8 @@
5657
"@typespec/mutator-framework": "workspace:~"
5758
},
5859
"devDependencies": {
60+
"@alloy-js/cli": "^0.22.0",
61+
"@alloy-js/rollup-plugin": "^0.1.0",
5962
"@types/node": "~22.13.13",
6063
"@typespec/compiler": "workspace:~",
6164
"@typespec/emitter-framework": "workspace:~",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { type ModelProperty, getDoc, getDeprecationDetails } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
import { isNullable, hasNullableElements } from "../../lib/nullable.js";
5+
import { GraphQLTypeExpression } from "./type-expression.js";
6+
7+
export interface FieldProps {
8+
/** The model property to render as a field */
9+
property: ModelProperty;
10+
/** Whether this field is in an input type context */
11+
isInput: boolean;
12+
}
13+
14+
export function Field(props: FieldProps) {
15+
const { program } = useTsp();
16+
17+
const doc = getDoc(program, props.property);
18+
const deprecation = getDeprecationDetails(program, props.property);
19+
20+
return (
21+
<GraphQLTypeExpression
22+
type={props.property.type}
23+
isOptional={props.property.optional}
24+
isInput={props.isInput}
25+
isNullable={isNullable(program, props.property)}
26+
hasNullableElements={hasNullableElements(program, props.property)}
27+
targetType={props.property}
28+
>
29+
{(typeInfo) => {
30+
if (props.isInput) {
31+
return (
32+
<gql.InputField
33+
name={props.property.name}
34+
type={typeInfo.typeName}
35+
nonNull={typeInfo.isList ? typeInfo.itemNonNull : typeInfo.isNonNull}
36+
description={doc}
37+
deprecated={deprecation ? deprecation.message : undefined}
38+
>
39+
{typeInfo.isList ? (
40+
<gql.InputField.List nonNull={typeInfo.isNonNull} />
41+
) : undefined}
42+
</gql.InputField>
43+
);
44+
}
45+
46+
return (
47+
<gql.Field
48+
name={props.property.name}
49+
type={typeInfo.typeName}
50+
nonNull={typeInfo.isList ? typeInfo.itemNonNull : typeInfo.isNonNull}
51+
description={doc}
52+
deprecated={deprecation ? deprecation.message : undefined}
53+
>
54+
{typeInfo.isList ? (
55+
<gql.Field.List nonNull={typeInfo.isNonNull} />
56+
) : undefined}
57+
</gql.Field>
58+
);
59+
}}
60+
</GraphQLTypeExpression>
61+
);
62+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { Field, type FieldProps } from "./field.js";
2+
export { OperationField, type OperationFieldProps } from "./operation-field.js";
3+
export {
4+
GraphQLTypeExpression,
5+
type GraphQLTypeExpressionProps,
6+
type GraphQLTypeInfo,
7+
} from "./type-expression.js";
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { type Operation, getDoc, getDeprecationDetails } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
import { isNullable, hasNullableElements } from "../../lib/nullable.js";
5+
import { GraphQLTypeExpression } from "./type-expression.js";
6+
7+
export interface OperationFieldProps {
8+
/** The operation to render as a field */
9+
operation: Operation;
10+
}
11+
12+
/**
13+
* Renders an operation as a field with arguments, used for @operationFields.
14+
*/
15+
export function OperationField(props: OperationFieldProps) {
16+
const { program } = useTsp();
17+
const params = Array.from(props.operation.parameters.properties.values());
18+
const doc = getDoc(program, props.operation);
19+
const deprecation = getDeprecationDetails(program, props.operation);
20+
21+
return (
22+
<GraphQLTypeExpression
23+
type={props.operation.returnType}
24+
isOptional={false}
25+
isInput={false}
26+
isNullable={isNullable(program, props.operation)}
27+
targetType={props.operation}
28+
>
29+
{(returnTypeInfo) => (
30+
<gql.Field
31+
name={props.operation.name}
32+
type={returnTypeInfo.typeName}
33+
nonNull={
34+
returnTypeInfo.isList
35+
? returnTypeInfo.itemNonNull
36+
: returnTypeInfo.isNonNull
37+
}
38+
description={doc}
39+
deprecated={deprecation ? deprecation.message : undefined}
40+
>
41+
{returnTypeInfo.isList ? (
42+
<gql.Field.List nonNull={returnTypeInfo.isNonNull} />
43+
) : undefined}
44+
{params.map((param) => (
45+
<GraphQLTypeExpression
46+
type={param.type}
47+
isOptional={param.optional}
48+
isInput={true}
49+
isNullable={isNullable(program, param)}
50+
hasNullableElements={hasNullableElements(program, param)}
51+
targetType={param}
52+
>
53+
{(paramTypeInfo) => (
54+
<gql.InputValue
55+
name={param.name}
56+
type={paramTypeInfo.typeName}
57+
nonNull={
58+
paramTypeInfo.isList
59+
? paramTypeInfo.itemNonNull
60+
: paramTypeInfo.isNonNull
61+
}
62+
description={getDoc(program, param)}
63+
deprecated={
64+
getDeprecationDetails(program, param)?.message
65+
}
66+
>
67+
{paramTypeInfo.isList ? (
68+
<gql.InputValue.List
69+
nonNull={paramTypeInfo.isNonNull}
70+
/>
71+
) : undefined}
72+
</gql.InputValue>
73+
)}
74+
</GraphQLTypeExpression>
75+
))}
76+
</gql.Field>
77+
)}
78+
</GraphQLTypeExpression>
79+
);
80+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
type Type,
3+
type Scalar,
4+
type ModelProperty,
5+
getEncode,
6+
isUnknownType,
7+
} from "@typespec/compiler";
8+
import { type Children } from "@alloy-js/core";
9+
import { useTsp } from "@typespec/emitter-framework";
10+
import { useGraphQLSchema } from "../../context/index.js";
11+
import { isNullable } from "../../lib/nullable.js";
12+
import { unwrapNullableUnion, getUnionName } from "../../lib/type-utils.js";
13+
import { getGraphQLBuiltinName, getScalarMapping } from "../../lib/scalar-mappings.js";
14+
15+
/**
16+
* Information about a resolved GraphQL type
17+
*/
18+
export interface GraphQLTypeInfo {
19+
/** The base type name (without wrappers) */
20+
typeName: string;
21+
/** Whether this is a list type */
22+
isList: boolean;
23+
/** Whether the field itself is non-null */
24+
isNonNull: boolean;
25+
/** Whether list items are non-null (only meaningful if isList is true) */
26+
itemNonNull: boolean;
27+
}
28+
29+
export interface GraphQLTypeExpressionProps {
30+
type: Type;
31+
isOptional: boolean;
32+
/** Whether this type is in an input position (operation parameter or input model field) */
33+
isInput: boolean;
34+
/** Whether this type was marked nullable (from property-level tracking) */
35+
isNullable?: boolean;
36+
/** Whether this property's array elements were originally T | null (from property-level tracking) */
37+
hasNullableElements?: boolean;
38+
/** The property or parameter that contains the type (for @encode checking) */
39+
targetType?: Type;
40+
children: (typeInfo: GraphQLTypeInfo) => Children;
41+
}
42+
43+
export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) {
44+
const { $, program } = useTsp();
45+
const { modelVariants } = useGraphQLSchema();
46+
47+
const nullable = props.isNullable || isNullable(program, props.type);
48+
49+
// Input fields are non-null unless nullable; optionality is expressed via
50+
// default values. Output fields are non-null unless optional or nullable.
51+
const isNonNull = nullable ? false : props.isInput || !props.isOptional;
52+
53+
// Unwrap T | null unions the mutation engine didn't process (e.g., array
54+
// elements, operation parameters that arrive here still wrapped).
55+
if ($.union.is(props.type)) {
56+
const innerType = unwrapNullableUnion(props.type);
57+
if (innerType) {
58+
return (
59+
<GraphQLTypeExpression
60+
type={innerType}
61+
isOptional={props.isOptional}
62+
isInput={props.isInput}
63+
isNullable={true}
64+
targetType={props.targetType}
65+
>
66+
{(innerInfo) =>
67+
props.children({
68+
...innerInfo,
69+
isNonNull: false,
70+
})
71+
}
72+
</GraphQLTypeExpression>
73+
);
74+
}
75+
}
76+
77+
if ($.array.is(props.type)) {
78+
const elementType = $.array.getElementType(props.type);
79+
// Element nullability: from mutation engine property-level tracking, from the
80+
// element type's own state map, or from an inline T | null union still present.
81+
const elementIsNullable =
82+
props.hasNullableElements ||
83+
isNullable(program, elementType) ||
84+
($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined);
85+
86+
return (
87+
<GraphQLTypeExpression
88+
type={elementType}
89+
isOptional={false}
90+
isInput={props.isInput}
91+
isNullable={elementIsNullable}
92+
targetType={props.targetType}
93+
>
94+
{(elementInfo) =>
95+
props.children({
96+
typeName: elementInfo.typeName,
97+
isList: true,
98+
isNonNull,
99+
itemNonNull: !elementIsNullable,
100+
})
101+
}
102+
</GraphQLTypeExpression>
103+
);
104+
}
105+
106+
const typeName = resolveBaseTypeName();
107+
108+
return props.children({
109+
typeName,
110+
isList: false,
111+
isNonNull,
112+
itemNonNull: false,
113+
});
114+
115+
function resolveBaseTypeName(): string {
116+
const type = props.type;
117+
118+
if (isUnknownType(type)) {
119+
return "Unknown";
120+
}
121+
122+
if ($.scalar.is(type)) {
123+
const builtinName = getGraphQLBuiltinName(program, type);
124+
if (builtinName) return builtinName;
125+
126+
// Std scalars with encoding-specific mappings (e.g., bytes + base64 -> Bytes)
127+
if (program.checker.isStdType(type)) {
128+
if (
129+
props.targetType &&
130+
($.scalar.is(props.targetType) || props.targetType.kind === "ModelProperty")
131+
) {
132+
const encodeData = getEncode(
133+
program,
134+
props.targetType as Scalar | ModelProperty,
135+
);
136+
const mapping = getScalarMapping(program, type, encodeData?.encoding);
137+
if (mapping) return mapping.graphqlName;
138+
}
139+
140+
const mapping = getScalarMapping(program, type);
141+
if (mapping) return mapping.graphqlName;
142+
}
143+
144+
return type.name;
145+
}
146+
147+
if ($.model.is(type)) {
148+
// Both input and output variants share the same source model identity,
149+
// so we use name-based lookup to determine if both variants exist.
150+
const hasOutputVariant = modelVariants.outputModels.has(type.name);
151+
const hasInputVariant = modelVariants.inputModels.has(type.name);
152+
153+
if (props.isInput && hasOutputVariant && hasInputVariant) {
154+
return `${type.name}Input`;
155+
}
156+
return type.name;
157+
}
158+
159+
if ($.enum.is(type)) {
160+
return type.name;
161+
}
162+
163+
if ($.union.is(type)) {
164+
return getUnionName(type, program);
165+
}
166+
167+
throw new Error(
168+
`Unexpected type kind "${type.kind}" in resolveBaseTypeName. ` +
169+
`This is a bug in the GraphQL emitter.`,
170+
);
171+
}
172+
}

0 commit comments

Comments
 (0)