Skip to content
Draft
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
63 changes: 58 additions & 5 deletions packages/graphql/src/mutation-engine/engine.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {
resolveUsages,
UsageFlags,
type Enum,
type Model,
type Namespace,
type Operation,
type Program,
type Scalar,
type UsageTracker,
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import {
Expand Down Expand Up @@ -47,9 +50,20 @@ const graphqlMutationRegistry = {
Intrinsic: SimpleIntrinsicMutation,
};

/**
* Result of mutating a model with usage awareness.
* Contains separate mutations for input and output variants when applicable.
*/
export interface ModelMutationResult {
/** The input variant mutation (with "Input" suffix), if the model is used as input */
input?: GraphQLModelMutation;
/** The output variant mutation (no suffix), if the model is used as output */
output?: GraphQLModelMutation;
}

/**
* GraphQL mutation engine that applies GraphQL-specific transformations
* to TypeSpec types, such as name sanitization.
* to TypeSpec types, such as name sanitization and input/output splitting.
*/
export class GraphQLMutationEngine {
/**
Expand All @@ -58,16 +72,55 @@ export class GraphQLMutationEngine {
*/
private engine;

constructor(program: Program, _namespace: Namespace) {
/** Usage tracker for types in the namespace */
private usageTracker: UsageTracker;

constructor(program: Program, namespace: Namespace) {
const tk = $(program);
this.engine = new MutationEngine(tk, graphqlMutationRegistry);

// Resolve usages once at construction time
this.usageTracker = resolveUsages(namespace);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an expensive operation so we probably don't want to do it in the constructor.

Expensive calls can be made lazily; i.e. if we ask for usage information (in getUsage) and we have not yet resolved usages, we do so (and store the result). Callers should also have the flexibility to trigger the resolve explicitly to optimize their call flow.

}

/**
* Get the usage flags for a model.
*/
getUsage(model: Model): UsageFlags {
const isInput = this.usageTracker.isUsedAs(model, UsageFlags.Input);
const isOutput = this.usageTracker.isUsedAs(model, UsageFlags.Output);

if (isInput && isOutput) {
return UsageFlags.Input | UsageFlags.Output;
} else if (isInput) {
return UsageFlags.Input;
} else if (isOutput) {
return UsageFlags.Output;
}
return UsageFlags.None;
}

/**
* Mutate a model, applying GraphQL name sanitization.
* Mutate a model with usage awareness.
* Returns separate input/output mutations based on how the model is used.
*/
mutateModel(model: Model): GraphQLModelMutation {
return this.engine.mutate(model, new GraphQLMutationOptions()) as GraphQLModelMutation;
mutateModel(model: Model): ModelMutationResult {
const usage = this.getUsage(model);
const result: ModelMutationResult = {};

// Create output mutation if used as output (or no usage info)
if (usage & UsageFlags.Output || usage === UsageFlags.None) {
const outputOptions = new GraphQLMutationOptions(UsageFlags.Output);
result.output = this.engine.mutate(model, outputOptions) as GraphQLModelMutation;
}

// Create input mutation if used as input
if (usage & UsageFlags.Input) {
const inputOptions = new GraphQLMutationOptions(UsageFlags.Input);
result.input = this.engine.mutate(model, inputOptions) as GraphQLModelMutation;
}

return result;
}

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/graphql/src/mutation-engine/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export { GraphQLMutationEngine, createGraphQLMutationEngine } from "./engine.js";
export {
GraphQLMutationEngine,
createGraphQLMutationEngine,
type ModelMutationResult,
} from "./engine.js";
export {
GraphQLEnumMemberMutation,
GraphQLEnumMutation,
Expand Down
18 changes: 15 additions & 3 deletions packages/graphql/src/mutation-engine/mutations/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MemberType, Model } from "@typespec/compiler";
import { UsageFlags, type MemberType, type Model } from "@typespec/compiler";
import {
SimpleModelMutation,
type MutationInfo,
Expand All @@ -7,11 +7,15 @@ import {
type SimpleMutations,
} from "@typespec/mutator-framework";
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
import type { GraphQLMutationOptions } from "../options.js";

/**
* GraphQL-specific Model mutation.
* GraphQL-specific Model mutation that sanitizes names for GraphQL compatibility.
* Adds "Input" suffix when the model is used as an input type.
*/
export class GraphQLModelMutation extends SimpleModelMutation<SimpleMutationOptions> {
private graphqlOptions: GraphQLMutationOptions;

constructor(
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
sourceType: Model,
Expand All @@ -20,12 +24,20 @@ export class GraphQLModelMutation extends SimpleModelMutation<SimpleMutationOpti
info: MutationInfo,
) {
super(engine as any, sourceType, referenceTypes, options, info);
this.graphqlOptions = options as GraphQLMutationOptions;
}

mutate() {
// Apply GraphQL name sanitization
this.mutationNode.mutate((model) => {
model.name = sanitizeNameForGraphQL(model.name);
let name = sanitizeNameForGraphQL(model.name);

// Add "Input" suffix for input types
if (this.graphqlOptions.usageFlag === UsageFlags.Input) {
name = `${name}Input`;
}

model.name = name;
});
super.mutate();
}
Expand Down
32 changes: 29 additions & 3 deletions packages/graphql/src/mutation-engine/options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { UsageFlags } from "@typespec/compiler";
import { SimpleMutationOptions } from "@typespec/mutator-framework";

/**
* GraphQL-specific mutation options.
*
* Currently a simple wrapper around SimpleMutationOptions.
* Can be extended in the future to support additional GraphQL-specific options.
* Extends SimpleMutationOptions with usage-aware mutation key support,
* enabling separate mutations for input vs output type variants.
*/
export class GraphQLMutationOptions extends SimpleMutationOptions {}
export class GraphQLMutationOptions extends SimpleMutationOptions {
/**
* The usage flag indicating whether this mutation is for input or output usage.
* Used to generate separate mutations for the same type when used in both contexts.
*/
readonly usageFlag: UsageFlags;

constructor(usageFlag: UsageFlags = UsageFlags.None) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's valid for me to do

new GraphQLMutationOptions(usageFlag: UsageFlags.Input | UsageFlags.Output);

or any other combination of the flags.

So we either need to account for this in mutationKey(), or do an explicit check at construction time that usageFlag is only the values that we expect.

super();
this.usageFlag = usageFlag;
}

/**
* Override mutationKey to include usage flag.
* This ensures the mutation engine caches separate mutations for input vs output variants.
*/
override get mutationKey(): string {
const baseKey = super.mutationKey;
if (this.usageFlag === UsageFlags.Input) {
return `${baseKey}:input`;
} else if (this.usageFlag === UsageFlags.Output) {
return `${baseKey}:output`;
}
return baseKey;
}
}
27 changes: 22 additions & 5 deletions packages/graphql/src/schema-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,34 @@ class GraphQLSchemaEmitter {
this.registry.addEnum(mutation.mutatedType);
},
model: (node: Model) => {
const mutation = this.engine.mutateModel(node);
// TODO: Handle input/output variants
this.registry.addModel(mutation.mutatedType, UsageFlags.Output);
// Mutate the model - returns input/output variants
const result = this.engine.mutateModel(node);

// Register output variant if present
if (result.output) {
this.registry.addModel(result.output.mutatedType, UsageFlags.Output);
}

// Register input variant if present
if (result.input) {
this.registry.addModel(result.input.mutatedType, UsageFlags.Input);
}
},
exitEnum: (node: Enum) => {
const mutation = this.engine.mutateEnum(node);
this.registry.materializeEnum(mutation.mutatedType.name);
},
exitModel: (node: Model) => {
const mutation = this.engine.mutateModel(node);
this.registry.materializeModel(mutation.mutatedType.name);
// Materialize both input and output variants
const result = this.engine.mutateModel(node);

if (result.output) {
this.registry.materializeModel(result.output.mutatedType.name);
}

if (result.input) {
this.registry.materializeModel(result.input.mutatedType.name);
}
},
};
}
Expand Down
35 changes: 25 additions & 10 deletions packages/graphql/src/type-maps/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
GraphQLString,
type GraphQLFieldConfigMap,
type GraphQLInputFieldConfigMap,
type GraphQLInputType,
type GraphQLOutputType,
} from "graphql";
import { TypeMap, type TSPContext, type TypeKey } from "../type-maps.js";

Expand Down Expand Up @@ -32,42 +34,55 @@ export class ModelTypeMap extends TypeMap<Model, GraphQLObjectType | GraphQLInpu

// Create InputObjectType for input usage, ObjectType for output
if (context.usageFlag === UsageFlags.Input) {
return this.materializeInputType(name, tspModel);
return this.materializeInputType(tspModel, name);
}
return this.materializeOutputType(name, tspModel);
return this.materializeOutputType(tspModel, name);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to pass the name explicitly here; can't we access the name property on the tspModel in the functions we're calling?


private materializeOutputType(name: string, tspModel: Model): GraphQLObjectType {
/**
* Materialize as a GraphQLObjectType (output type).
*/
private materializeOutputType(tspModel: Model, name: string): GraphQLObjectType {
const fields: GraphQLFieldConfigMap<unknown, unknown> = {};

for (const [propName, prop] of tspModel.properties) {
fields[propName] = {
type: this.mapPropertyType(prop.type),
// TODO: Add description from doc comments
type: this.mapOutputType(prop.type),
};
}

return new GraphQLObjectType({ name, fields });
}

private materializeInputType(name: string, tspModel: Model): GraphQLInputObjectType {
/**
* Materialize as a GraphQLInputObjectType (input type).
*/
private materializeInputType(tspModel: Model, name: string): GraphQLInputObjectType {
const fields: GraphQLInputFieldConfigMap = {};

for (const [propName, prop] of tspModel.properties) {
fields[propName] = {
type: this.mapPropertyType(prop.type),
// TODO: Add description from doc comments
type: this.mapInputType(prop.type),
};
}

return new GraphQLInputObjectType({ name, fields });
}

/**
* Map a TypeSpec property type to a GraphQL String type.
* Map a TypeSpec property type to a GraphQL output type.
* TODO: Implement full type mapping with references to other registered types.
*/
private mapOutputType(_type: unknown): GraphQLOutputType {
// Placeholder - will need to resolve references to other types
return GraphQLString;
}

/**
* Map a TypeSpec property type to a GraphQL input type.
* TODO: Implement full type mapping with references to other registered types.
*/
private mapPropertyType(_type: unknown): typeof GraphQLString {
private mapInputType(_type: unknown): GraphQLInputType {
// Placeholder - will need to resolve references to other types
return GraphQLString;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/graphql/test/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ namespace MyLibrary {
Mystery,
Fantasy,
}

op getBooks(): Book[];
op createBook(book: Book): Book;
}
Loading