From 7da971e6348a4ab23b6d812150c0c1da5dbac042 Mon Sep 17 00:00:00 2001 From: Steve Rice Date: Mon, 25 Nov 2024 14:26:24 -0800 Subject: [PATCH] Implement `@operationFields` decorator 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. --- packages/graphql/lib/main.tsp | 1 + packages/graphql/lib/operation-fields.tsp | 20 ++ packages/graphql/src/lib.ts | 18 +- packages/graphql/src/lib/operation-fields.ts | 114 +++++++++++ packages/graphql/src/lib/utils.ts | 47 +++++ packages/graphql/src/tsp-index.ts | 2 + .../graphql/test/operation-fields.test.ts | 187 ++++++++++++++++++ 7 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 packages/graphql/lib/operation-fields.tsp create mode 100644 packages/graphql/src/lib/operation-fields.ts create mode 100644 packages/graphql/src/lib/utils.ts create mode 100644 packages/graphql/test/operation-fields.test.ts diff --git a/packages/graphql/lib/main.tsp b/packages/graphql/lib/main.tsp index 9991233a2c3..909982c8b60 100644 --- a/packages/graphql/lib/main.tsp +++ b/packages/graphql/lib/main.tsp @@ -1 +1,2 @@ +import "./operation-fields.tsp"; import "./schema.tsp"; diff --git a/packages/graphql/lib/operation-fields.tsp b/packages/graphql/lib/operation-fields.tsp new file mode 100644 index 00000000000..80f5dcc48fb --- /dev/null +++ b/packages/graphql/lib/operation-fields.tsp @@ -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[]); diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index 27d3acf1799..bb43aa3098f 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -1,4 +1,4 @@ -import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler"; +import { createTypeSpecLibrary, paramMessage, type JSONSchemaType } from "@typespec/compiler"; export const NAMESPACE = "TypeSpec.GraphQL"; @@ -93,11 +93,25 @@ const EmitterOptionsSchema: JSONSchemaType = { 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, }, state: { + operationFields: { description: "State for the @operationFields decorator." }, schema: { description: "State for the @schema decorator." }, }, } as const; diff --git a/packages/graphql/src/lib/operation-fields.ts b/packages/graphql/src/lib/operation-fields.ts new file mode 100644 index 00000000000..a7c6b3f5d41 --- /dev/null +++ b/packages/graphql/src/lib/operation-fields.ts @@ -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 +>(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 { + return getOperationFieldsInternal(program, model) || new Set(); +} + +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); + } + } + } +}; diff --git a/packages/graphql/src/lib/utils.ts b/packages/graphql/src/lib/utils.ts new file mode 100644 index 00000000000..8111f6c8db8 --- /dev/null +++ b/packages/graphql/src/lib/utils.ts @@ -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); +} diff --git a/packages/graphql/src/tsp-index.ts b/packages/graphql/src/tsp-index.ts index dec5cda6d81..2e698911fef 100644 --- a/packages/graphql/src/tsp-index.ts +++ b/packages/graphql/src/tsp-index.ts @@ -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, }, }; diff --git a/packages/graphql/test/operation-fields.test.ts b/packages/graphql/test/operation-fields.test.ts new file mode 100644 index 00000000000..c7b9c26b736 --- /dev/null +++ b/packages/graphql/test/operation-fields.test.ts @@ -0,0 +1,187 @@ +import type { Model, Operation } from "@typespec/compiler"; +import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOperationFields } from "../src/lib/operation-fields.js"; +import { compileAndDiagnose, diagnose } from "./test-host.js"; + +describe("@operationFields", () => { + it("can add an operation to the model", async () => { + const [program, { TestModel, testOperation }, diagnostics] = await compileAndDiagnose<{ + TestModel: Model; + testOperation: Operation; + }>(` + @test op testOperation(): void; + + @operationFields(testOperation) + @test model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + + expect(getOperationFields(program, TestModel)).toContain(testOperation); + }); + + it("can add an interface to the model", async () => { + const [program, { TestModel, testOperation }, diagnostics] = await compileAndDiagnose<{ + TestModel: Model; + testOperation: Operation; + }>(` + interface TestInterface { + @test op testOperation(): void; + } + + @operationFields(TestInterface) + @test model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + + expect(getOperationFields(program, TestModel)).toContain(testOperation); + }); + + it("can add an multiple operations to the model", async () => { + const [program, { TestModel, testOperation1, testOperation2, testOperation3 }, diagnostics] = + await compileAndDiagnose<{ + TestModel: Model; + testOperation1: Operation; + testOperation2: Operation; + testOperation3: Operation; + }>(` + interface TestInterface { + @test op testOperation1(): void; + @test op testOperation2(): void; + } + + @test op testOperation3(): void; + + @operationFields(TestInterface, testOperation3) + @test model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + + expect(getOperationFields(program, TestModel)).toContain(testOperation1); + expect(getOperationFields(program, TestModel)).toContain(testOperation2); + expect(getOperationFields(program, TestModel)).toContain(testOperation3); + }); + + it("will add duplicate operations with a warning", async () => { + const [program, { TestModel, testOperation }, diagnostics] = await compileAndDiagnose<{ + TestModel: Model; + testOperation: Operation; + }>(` + interface TestInterface { + @test op testOperation(): void; + } + + @operationFields(TestInterface, TestInterface.testOperation) + @test model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-duplicate", + message: "Operation `testOperation` is defined multiple times on `TestModel`.", + }); + + expect(getOperationFields(program, TestModel)).toContain(testOperation); + }); + + describe("conflicts", () => { + it("does not allow adding operations that conflict with a field", async () => { + const diagnostics = await diagnose(` + op foo(): void; + + @operationFields(foo) + model TestModel { + foo: string; + } + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: "Operation `foo` conflicts with an existing property on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in return type", async () => { + const diagnostics = await diagnose(` + op testOperation(): string; + + interface TestInterface { + op testOperation(): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in number of arguments", async () => { + const diagnostics = await diagnose(` + op testOperation(a: string, b: integer): void; + + interface TestInterface { + op testOperation(a: string): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in argument type", async () => { + const diagnostics = await diagnose(` + op testOperation(a: string): void; + + interface TestInterface { + op testOperation(a: integer): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in argument name", async () => { + const diagnostics = await diagnose(` + op testOperation(a: string): void; + + interface TestInterface { + op testOperation(b: string): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("allows adding operations with a different argument order", async () => { + const diagnostics = await diagnose(` + op testOperation(a: string, b: integer): void; + + interface TestInterface { + op testOperation(b: integer, a: string): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + }); + }); +});