Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3673956
Replace state maps with decorators for mutation engine metadata
FionaBronwen Apr 16, 2026
5f26c25
Add Alloy infrastructure, context system, and field components
FionaBronwen Apr 14, 2026
157e529
Remove ModelVariants - mutation engine handles input/output naming
FionaBronwen May 1, 2026
a4248e4
Remove dead code from type-utils.ts
FionaBronwen May 1, 2026
3dc3204
Replace auxiliary data structures with TypeGraph in schema context
FionaBronwen Jun 2, 2026
ea85714
Fix nullability: optional fields should be nullable per GraphQL spec
FionaBronwen Jun 3, 2026
c1e2690
Replace legacy emitter with foundation skeleton and diagnostics
FionaBronwen Apr 12, 2026
27f0cc6
Move schema mutation orchestration into the mutation engine
FionaBronwen Apr 21, 2026
73724f4
Move ScalarVariant type to schema-mutator.ts
FionaBronwen Jun 3, 2026
faabf5c
Add simple type components (Enum, Scalar, Union) with component tests
FionaBronwen Apr 12, 2026
0ca300e
Fix review findings: extract isScalarLikeType, safe union type assertion
FionaBronwen Apr 12, 2026
d7744e6
Refactor component tests to use toMatchInlineSnapshot()
FionaBronwen May 1, 2026
390fba6
Add isScalarLikeType to type-utils.ts (lost during rebase)
FionaBronwen Jun 2, 2026
7628d99
Adapt components to TypeGraph context
FionaBronwen Jun 3, 2026
ba72c66
Improve mutation engine input/output type handling
FionaBronwen Apr 28, 2026
73b6a36
Fix schema-mutator to mutate models separately for input/output contexts
FionaBronwen Apr 28, 2026
efd80c4
Fix double-suffix and oneOf variant mutation bugs
FionaBronwen Apr 28, 2026
329eb4a
Add Object, Interface, and Input type components with tests
FionaBronwen Apr 12, 2026
0994b4b
Fix review findings: document implements clause behavior, remove unus…
FionaBronwen Apr 12, 2026
daf7d4d
Add test coverage for @operationFields, model refs, and empty types
FionaBronwen Apr 28, 2026
9834d8c
Remove modelVariants workaround from components
FionaBronwen Apr 28, 2026
865b9ea
Simplify comments and tests in type components
FionaBronwen May 1, 2026
7a1c0e2
Refactor component tests to use toMatchInlineSnapshot()
FionaBronwen May 1, 2026
372c7da
Fix test: optional input fields should be nullable per GraphQL spec
FionaBronwen Jun 3, 2026
fe4f8bc
Add operation components, orchestrator, and emitter rendering
FionaBronwen Apr 20, 2026
08191ce
Add unit tests for operation type components
FionaBronwen May 1, 2026
d511036
Adapt emitter and type-collections to props-based architecture
FionaBronwen Jun 3, 2026
a0d3d2f
Fix test: optional parameters should be nullable per GraphQL spec
FionaBronwen Jun 3, 2026
c42edc5
Update follow-up-items: mark nullability bug as resolved
FionaBronwen Jun 3, 2026
5d958fc
Add integration test suite for GraphQL emitter
FionaBronwen Apr 20, 2026
8c7519a
Add test coverage for subscriptions, interfaces, operationFields, and…
FionaBronwen May 5, 2026
8b368d5
Fix test expectations: optional fields are nullable per GraphQL spec
FionaBronwen Jun 3, 2026
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
72 changes: 72 additions & 0 deletions packages/graphql/.claude/follow-up-items.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# GraphQL Emitter Follow-up Items

## ~~Input Type Nullability Bug~~ (RESOLVED)

**Status**: Fixed in PR #77 (commit ea85714d2)

**Original issue**: Optional fields in input types were being made non-null.

**Resolution**: Per the GraphQL spec, "nullability directly determines whether a field is required." Optional fields (`?`) should be nullable in both input and output contexts. This also enables circular references in input types (e.g., `Author.friend?: Author` → `friend: AuthorInput` is valid because it's nullable).

**Correct behavior** (now implemented):

| TypeSpec | Output | Input |
|--------------------|----------|----------|
| a: string | String! | String! |
| b?: string | String | String |
| c: string \| null | String | String |

---

## @oneOf Input Union Bug (Priority: Medium)

**Issue**: Unions used in input context (e.g., mutation parameters) should be converted to `@oneOf` input objects per the GraphQL spec, but the emitter crashes with "Unknown GraphQL type" when attempting this.

**Test case that exposes the bug**:
```typespec
@schema
namespace TestNamespace {
model TextContent {
text: string;
}

model ImageContent {
url: string;
}

union Content {
text: TextContent,
image: ImageContent,
}

model Post {
id: string;
content: Content;
}

@query
op getPost(id: string): Post;

@mutation
op createPost(content: Content): Post;
}
```

**Error**:
```
Error: Unknown GraphQL type "ContentInput".
at resolveNamedType (src/schema/build/types.ts:100:11)
```

**Root cause**: The `GraphQLUnionMutation.mutateAsOneOfInput()` correctly creates the `@oneOf` input model (e.g., `ContentInput`), but this synthetic model is not being registered in the type registry that `buildArgsMap` uses to resolve argument types.

**Expected behavior**:
- In output context: `union Content = TextContent | ImageContent`
- In input context: `input ContentInput @oneOf { text: TextContentInput, image: ImageContentInput }`

**Files to investigate**:
- `src/mutation-engine/mutations/union.ts` - `mutateAsOneOfInput()` creates the @oneOf model
- `src/schema/build/types.ts` - where the type registry is built and `resolveNamedType` fails
- `src/mutation-engine/schema-mutator.ts` - may need to register synthetic types

**Related**: Similar bug exists for complex input types used in `@operationFields` arguments (the `FilterOptions` model isn't discovered when used only as an operation field argument type).
2 changes: 2 additions & 0 deletions packages/graphql/lib/main.tsp
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import "./interface.tsp";
import "./nullable.tsp";
import "./one-of.tsp";
import "./operation-fields.tsp";
import "./operation-kind.tsp";
import "./scalars.tsp";
Expand Down
22 changes: 22 additions & 0 deletions packages/graphql/lib/nullable.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import "../dist/src/lib/nullable.js";

using TypeSpec.Reflection;

namespace TypeSpec.GraphQL;

/**
* Mark a field, operation, or type as nullable in the emitted GraphQL schema.
*
* Applied automatically by the mutation engine when it strips `| null` from
* union types. The decorator's presence on the type's `decorators` array is
* the signal — the implementation is a no-op.
*/
extern dec nullable(target: ModelProperty | Operation | Union | Model);

/**
* Mark a field or operation as having nullable array elements in the emitted GraphQL schema.
*
* Applied automatically by the mutation engine when it detects `Array<T | null>`
* patterns. Causes the emitter to emit `[T]` instead of `[T!]`.
*/
extern dec nullableElements(target: ModelProperty | Operation);
16 changes: 16 additions & 0 deletions packages/graphql/lib/one-of.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import "../dist/src/lib/one-of.js";

using TypeSpec.Reflection;

namespace TypeSpec.GraphQL;

/**
* Mark a model as a `@oneOf` input object in the emitted GraphQL schema.
*
* This decorator is applied automatically by the mutation engine when it converts
* a union type in input context to a synthetic input object (since GraphQL unions
* are output-only). The emitter uses this to emit the `@oneOf` directive.
*
* @see https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects
*/
extern dec oneOf(target: Model);
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>
);
}
Loading