Skip to content

Commit

Permalink
Implement @operationFields decorator
Browse files Browse the repository at this point in the history
The `@operationFields` decorator is used to specify one or more operations that should be placed onto a GraphQL type as fields with arguments.

This is our solution for representing [GraphQL field arguments](https://spec.graphql.org/October2021/#sec-Field-Arguments) in TypeSpec, as TypeSpec does not support arguments on model properties.
  • Loading branch information
steverice committed Nov 26, 2024
1 parent cdceeeb commit 9279208
Show file tree
Hide file tree
Showing 7 changed files with 387 additions and 2 deletions.
1 change: 1 addition & 0 deletions packages/graphql/lib/main.tsp
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
import "./operation-fields.tsp";
import "./schema.tsp";
20 changes: 20 additions & 0 deletions packages/graphql/lib/operation-fields.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import "../dist/src/lib/operation-fields.js";

using TypeSpec.Reflection;

namespace TypeSpec.GraphQL;

alias OperationOrInterface = Operation | Interface;

/**
* Assign one or more operations or interfaces to act as fields with arguments on a model.
*
* @example
*
* ```typespec
* op followers(query: string): Person[];
*
* @operationFields(followers)
* model Person {}
*/
extern dec operationFields(target: Model, ...operations: OperationOrInterface[]);
18 changes: 16 additions & 2 deletions packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler";
import { createTypeSpecLibrary, paramMessage, type JSONSchemaType } from "@typespec/compiler";

export const NAMESPACE = "TypeSpec.GraphQL";

Expand Down Expand Up @@ -93,11 +93,25 @@ const EmitterOptionsSchema: JSONSchemaType<GraphQLEmitterOptions> = {

export const libDef = {
name: "@typespec/graphql",
diagnostics: {},
diagnostics: {
"operation-field-conflict": {
severity: "error",
messages: {
default: paramMessage`Operation \`${"operation"}\` conflicts with an existing ${"conflictType"} on model \`${"model"}\`.`,
},
},
"operation-field-duplicate": {
severity: "warning",
messages: {
default: paramMessage`Operation \`${"operation"}\` is defined multiple times on \`${"model"}\`.`,
},
},
},
emitter: {
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,
},
state: {
operationFields: { description: "State for the @operationFields decorator." },
schema: { description: "State for the @schema decorator." },
},
} as const;
Expand Down
114 changes: 114 additions & 0 deletions packages/graphql/src/lib/operation-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
walkPropertiesInherited,
type DecoratorContext,
type DecoratorFunction,
type Interface,
type Model,
type Operation,
type Program,
} from "@typespec/compiler";

// import { createTypeRelationChecker } from "../../../compiler/dist/src/core/type-relation-checker.js";

import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
import { useStateMap } from "./state-map.js";
import { operationsEqual } from "./utils.js";

// This will set the namespace for decorators implemented in this file
export const namespace = NAMESPACE;

const [getOperationFieldsInternal, setOperationFields, _getOperationFieldsMap] = useStateMap<
Model,
Set<Operation>
>(GraphQLKeys.operationFields);

/**
* Get the operation fields for a given model
* @param program Program
* @param model Model
* @returns Set of operations defined for the model
*/
export function getOperationFields(program: Program, model: Model): Set<Operation> {
return getOperationFieldsInternal(program, model) || new Set<Operation>();
}

function validateDuplicateProperties(
context: DecoratorContext,
model: Model,
operation: Operation,
) {
const operationFields = getOperationFields(context.program, model);
if (operationFields.has(operation)) {
reportDiagnostic(context.program, {
code: "operation-field-duplicate",
format: { operation: operation.name, model: model.name },
target: context.getArgumentTarget(0)!,
});
return false;
}
return true;
}

function validateNoConflictWithProperties(
context: DecoratorContext,
model: Model,
operation: Operation,
) {
const conflictTypes = [];
if ([...walkPropertiesInherited(model)].some((prop) => prop.name === operation.name)) {
conflictTypes.push("property"); // an operation and a property is always a conflict
}
const existingOperation = [...getOperationFields(context.program, model)].find(
(op) => op.name === operation.name,
);

if (existingOperation && !operationsEqual(existingOperation, operation)) {
conflictTypes.push("operation");
}
for (const conflictType of conflictTypes) {
reportDiagnostic(context.program, {
code: "operation-field-conflict",
format: { operation: operation.name, model: model.name, conflictType },
target: context.getArgumentTarget(0)!,
});
}
return conflictTypes.length === 0;
}

/**
* Add this operation to the model's operation fields.
* @param context DecoratorContext
* @param model Model
* @param operation Operation
*/
export function addOperationField(
context: DecoratorContext,
model: Model,
operation: Operation,
): void {
const operationFields = getOperationFields(context.program, model);
if (!validateDuplicateProperties(context, model, operation)) {
return;
}
if (!validateNoConflictWithProperties(context, model, operation)) {
return;
}
operationFields.add(operation);
setOperationFields(context.program, model, operationFields);
}

export const $operationFields: DecoratorFunction = (
context: DecoratorContext,
target: Model,
...operationOrInterfaces: (Operation | Interface)[]
): void => {
for (const operationOrInterface of operationOrInterfaces) {
if (operationOrInterface.kind === "Operation") {
addOperationField(context, target, operationOrInterface);
} else {
for (const [_, operation] of operationOrInterface.operations) {
addOperationField(context, target, operation);
}
}
}
};
47 changes: 47 additions & 0 deletions packages/graphql/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
walkPropertiesInherited,
type Model,
type ModelProperty,
type Operation,
} from "@typespec/compiler";

export function propertiesEqual(
prop1: ModelProperty,
prop2: ModelProperty,
ignoreNames: boolean = false,
): boolean {
if (!ignoreNames && prop1.name !== prop2.name) {
return false;
}
return prop1.type === prop2.type && prop1.optional === prop2.optional;
}

export function modelsEqual(model1: Model, model2: Model, ignoreNames: boolean = false): boolean {
if (!ignoreNames && model1.name !== model2.name) {
return false;
}
const model1Properties = new Set(walkPropertiesInherited(model1));
const model2Properties = new Set(walkPropertiesInherited(model2));
if (model1Properties.size !== model2Properties.size) {
return false;
}
if (
[...model1Properties].some(
(prop) => ![...model2Properties].some((p) => propertiesEqual(prop, p, false)),
)
) {
return false;
}
return true;
}

export function operationsEqual(
op1: Operation,
op2: Operation,
ignoreNames: boolean = false,
): boolean {
if (!ignoreNames && op1.name !== op2.name) {
return false;
}
return op1.returnType === op2.returnType && modelsEqual(op1.parameters, op2.parameters, true);
}
2 changes: 2 additions & 0 deletions packages/graphql/src/tsp-index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { DecoratorImplementations } from "@typespec/compiler";
import { NAMESPACE } from "./lib.js";
import { $operationFields } from "./lib/operation-fields.js";
import { $schema } from "./lib/schema.js";

export const $decorators: DecoratorImplementations = {
[NAMESPACE]: {
operationFields: $operationFields,
schema: $schema,
},
};
Loading

0 comments on commit 9279208

Please sign in to comment.