Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement @operationFields decorator #4

Open
wants to merge 1 commit into
base: feature/graphql
Choose a base branch
from
Open
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
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