diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42ce208..fe6ee0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [20.x, 22.x] steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 6275536..2a055bd 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ For the directive, the two conversions work like this: | semantic-to-nullable | `[Int] @semanticNonNull(levels: [0,1])` | `[Int]` | | semantic-to-strict | `[Int] @semanticNonNull(levels: [0,1])` | `[Int!]!` | +> [!NOTE] +> +> An existing strictly non-nullable type (`Int!`) will remain unchanged whether +> or not `@semanticNonNull` applies to that level. + ### `GraphQLSemanticNonNull` wrapper type How the `GraphQLSemanticNonNull` type is represented syntactically in SDL is yet @@ -187,3 +192,20 @@ import { schema as sourceSchema } from "./my-schema"; export const schema = semanticToStrict(sourceSchema); ``` + +## Advanced usage + +If you just want to convert a single `GraphQLFieldConfig` you can use the +`convertFieldConfig` method, passing the field config and `true` to convert +semantic non-null positions to strict non-nulls, or `false` if you want to +convert to nullable: + +```ts +const strictFieldConfig = convertFieldConfig(fieldConfig, true); +const nullableFieldConfig = convertFieldConfig(fieldConfig, false); +``` + +> [!NOTE] +> +> This method assumes that the fieldConfig has come from parsing an SDL string, +> and thus has an `astNode` that includes a `@semanticNonNull` directive. diff --git a/__tests__/graphql-pr-4192.test.mjs b/__tests__/graphql-pr-4192.test.mjs new file mode 100644 index 0000000..e0f2df5 --- /dev/null +++ b/__tests__/graphql-pr-4192.test.mjs @@ -0,0 +1,3 @@ +import { runTest } from "./runTest.mjs"; + +runTest("graphql-pr-4192"); diff --git a/__tests__/graphql15.test.mjs b/__tests__/graphql15.test.mjs new file mode 100644 index 0000000..19e6e08 --- /dev/null +++ b/__tests__/graphql15.test.mjs @@ -0,0 +1,3 @@ +import { runTest } from "./runTest.mjs"; + +runTest("graphql15"); diff --git a/__tests__/graphql16.test.mjs b/__tests__/graphql16.test.mjs new file mode 100644 index 0000000..688238f --- /dev/null +++ b/__tests__/graphql16.test.mjs @@ -0,0 +1,3 @@ +import { runTest } from "./runTest.mjs"; + +runTest("graphql16"); diff --git a/__tests__/graphql17.test.mjs b/__tests__/graphql17.test.mjs new file mode 100644 index 0000000..6ff52fd --- /dev/null +++ b/__tests__/graphql17.test.mjs @@ -0,0 +1,3 @@ +import { runTest } from "./runTest.mjs"; + +runTest("graphql17"); diff --git a/__tests__/index.test.mjs b/__tests__/index.test.mjs deleted file mode 100644 index 900551b..0000000 --- a/__tests__/index.test.mjs +++ /dev/null @@ -1,43 +0,0 @@ -// @ts-check - -import { test } from "node:test"; -import * as assert from "node:assert"; -import { semanticToStrict, semanticToNullable } from "../dist/index.js"; -import { buildSchema, printSchema } from "graphql"; -import { readdir, readFile } from "node:fs/promises"; - -const TEST_DIR = import.meta.dirname; -const files = await readdir(TEST_DIR); - -for (const file of files) { - if (file.endsWith(".test.graphql") && !file.startsWith(".")) { - test(file.replace(/\.test\.graphql$/, ""), async () => { - const sdl = await readFile(TEST_DIR + "/" + file, "utf8"); - const schema = buildSchema(sdl); - await test("semantic-to-strict", async () => { - const expectedSdl = await readFile( - TEST_DIR + "/snapshots/" + file.replace(".test.", ".strict."), - "utf8", - ); - const converted = semanticToStrict(schema); - assert.equal( - printSchema(converted).trim(), - expectedSdl.trim(), - "Expected semantic-to-strict to match", - ); - }); - await test("semantic-to-nullable", async () => { - const expectedSdl = await readFile( - TEST_DIR + "/snapshots/" + file.replace(".test.", ".nullable."), - "utf8", - ); - const converted = semanticToNullable(schema); - assert.equal( - printSchema(converted).trim(), - expectedSdl.trim(), - "Expected semantic-to-nullable to match", - ); - }); - }); - } -} diff --git a/__tests__/runTest.mjs b/__tests__/runTest.mjs new file mode 100644 index 0000000..d0b72a0 --- /dev/null +++ b/__tests__/runTest.mjs @@ -0,0 +1,68 @@ +// @ts-check + +import * as assert from "node:assert"; +import { readdir, readFile } from "node:fs/promises"; +import { test } from "node:test"; + +const TEST_DIR = import.meta.dirname; +const files = await readdir(TEST_DIR); +const skip = test.skip.bind(test); + +/** @param graphqlModuleName {string} */ +export const runTest = async (graphqlModuleName) => { + test(graphqlModuleName, async (t) => { + const mod = await import(graphqlModuleName); + const { default: defaultExport, ...namedExports } = mod; + const mockGraphql = t.mock.module("graphql", { + cache: true, + defaultExport, + namedExports, + }); + const graphql = await import("graphql"); + const { buildSchema, printSchema } = graphql; + const isSemanticNonNullType = /** @type {any} */ (graphql) + .isSemanticNonNullType; + + const { semanticToNullable, semanticToStrict } = await import( + `../dist/index.js?graphql=${graphqlModuleName}` + ); + + for (const file of files) { + if (file.endsWith(".test.graphql") && !file.startsWith(".")) { + const pureDirective = + file === "schema-with-directive-only.test.graphql"; + const maybeTest = + pureDirective || isSemanticNonNullType != null ? test : skip; + await maybeTest(file.replace(/\.test\.graphql$/, ""), async () => { + const sdl = await readFile(TEST_DIR + "/" + file, "utf8"); + const schema = buildSchema(sdl); + await test("semantic-to-strict", async () => { + const expectedSdl = await readFile( + TEST_DIR + "/snapshots/" + file.replace(".test.", ".strict."), + "utf8", + ); + const converted = semanticToStrict(schema); + assert.equal( + printSchema(converted).trim(), + expectedSdl.trim(), + "Expected semantic-to-strict to match", + ); + }); + await test("semantic-to-nullable", async () => { + const expectedSdl = await readFile( + TEST_DIR + "/snapshots/" + file.replace(".test.", ".nullable."), + "utf8", + ); + const converted = semanticToNullable(schema); + assert.equal( + printSchema(converted).trim(), + expectedSdl.trim(), + "Expected semantic-to-nullable to match", + ); + }); + }); + } + } + mockGraphql.restore(); + }); +}; diff --git a/__tests__/schema-with-directive-only.test.graphql b/__tests__/schema-with-directive-only.test.graphql new file mode 100644 index 0000000..424d543 --- /dev/null +++ b/__tests__/schema-with-directive-only.test.graphql @@ -0,0 +1,39 @@ +directive @semanticNonNull(levels: [Int!]) on FIELD_DEFINITION + +type Query { + allThings(includingArchived: Boolean, first: Int!): ThingConnection + @semanticNonNull +} + +type ThingConnection { + pageInfo: PageInfo! + nodes: [Thing] @semanticNonNull(levels: [0, 1]) +} + +type PageInfo { + startCursor: String @semanticNonNull(levels: [0]) + endCursor: String @semanticNonNull + hasNextPage: Boolean @semanticNonNull + hasPreviousPage: Boolean @semanticNonNull +} + +interface Thing { + id: ID! + name: String @semanticNonNull + description: String +} + +type Book implements Thing { + id: ID! + name: String @semanticNonNull + description: String + # Test that this non-null is retained + pages: Int! @semanticNonNull +} + +type Car implements Thing { + id: ID! + name: String @semanticNonNull + description: String + mileage: Float @semanticNonNull +} diff --git a/__tests__/schema-with-directive.test.graphql b/__tests__/schema-with-directive.test.graphql index 4835ba8..0fb67a8 100644 --- a/__tests__/schema-with-directive.test.graphql +++ b/__tests__/schema-with-directive.test.graphql @@ -28,7 +28,7 @@ type Book implements Thing { # Test that this semantic-non-null doesn't cause issues name: String* @semanticNonNull description: String - # Test that this non-null gets stripped + # Test that this non-null is retained pages: Int! @semanticNonNull } diff --git a/__tests__/schema.test.graphql b/__tests__/schema.test.graphql index 32d0562..61851dd 100644 --- a/__tests__/schema.test.graphql +++ b/__tests__/schema.test.graphql @@ -24,7 +24,7 @@ type Book implements Thing { id: ID! name: String* description: String - pages: Int* + pages: Int! } type Car implements Thing { diff --git a/__tests__/snapshots/schema-with-directive-only.nullable.graphql b/__tests__/snapshots/schema-with-directive-only.nullable.graphql new file mode 100644 index 0000000..21407b4 --- /dev/null +++ b/__tests__/snapshots/schema-with-directive-only.nullable.graphql @@ -0,0 +1,35 @@ +type Query { + allThings(includingArchived: Boolean, first: Int!): ThingConnection +} + +type ThingConnection { + pageInfo: PageInfo! + nodes: [Thing] +} + +type PageInfo { + startCursor: String + endCursor: String + hasNextPage: Boolean + hasPreviousPage: Boolean +} + +interface Thing { + id: ID! + name: String + description: String +} + +type Book implements Thing { + id: ID! + name: String + description: String + pages: Int! +} + +type Car implements Thing { + id: ID! + name: String + description: String + mileage: Float +} diff --git a/__tests__/snapshots/schema-with-directive-only.strict.graphql b/__tests__/snapshots/schema-with-directive-only.strict.graphql new file mode 100644 index 0000000..fb17adf --- /dev/null +++ b/__tests__/snapshots/schema-with-directive-only.strict.graphql @@ -0,0 +1,35 @@ +type Query { + allThings(includingArchived: Boolean, first: Int!): ThingConnection! +} + +type ThingConnection { + pageInfo: PageInfo! + nodes: [Thing!]! +} + +type PageInfo { + startCursor: String! + endCursor: String! + hasNextPage: Boolean! + hasPreviousPage: Boolean! +} + +interface Thing { + id: ID! + name: String! + description: String +} + +type Book implements Thing { + id: ID! + name: String! + description: String + pages: Int! +} + +type Car implements Thing { + id: ID! + name: String! + description: String + mileage: Float! +} diff --git a/__tests__/snapshots/schema-with-directive.nullable.graphql b/__tests__/snapshots/schema-with-directive.nullable.graphql index b1e92ee..21407b4 100644 --- a/__tests__/snapshots/schema-with-directive.nullable.graphql +++ b/__tests__/snapshots/schema-with-directive.nullable.graphql @@ -24,7 +24,7 @@ type Book implements Thing { id: ID! name: String description: String - pages: Int + pages: Int! } type Car implements Thing { diff --git a/__tests__/snapshots/schema.nullable.graphql b/__tests__/snapshots/schema.nullable.graphql index b1e92ee..21407b4 100644 --- a/__tests__/snapshots/schema.nullable.graphql +++ b/__tests__/snapshots/schema.nullable.graphql @@ -24,7 +24,7 @@ type Book implements Thing { id: ID! name: String description: String - pages: Int + pages: Int! } type Car implements Thing { diff --git a/package.json b/package.json index 5bb9018..09add16 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "prepack": "tsc && chmod +x dist/cli/*.js", - "test": "node --test", + "test": "node --test --experimental-test-module-mocks", "watch": "tsc --watch", "lint": "yarn prettier:check && eslint --ext .js,.jsx,.ts,.tsx,.graphql .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx,.graphql . --fix; prettier --cache --ignore-path .eslintignore --write '**/*.{js,jsx,ts,tsx,graphql,md,json}'", @@ -46,7 +46,7 @@ "author": "Benjie Gillam ", "license": "MIT", "dependencies": { - "graphql": "16.9.0-canary.pr.4192.1813397076f44a55e5798478e7321db9877de97a" + "graphql": "15.x | 16.x | 17.x" }, "devDependencies": { "@tsconfig/recommended": "^1.0.7", @@ -58,6 +58,10 @@ "eslint-plugin-import": "^2.28.1", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint_d": "^13.0.0", + "graphql-pr-4192": "npm:graphql@16.9.0-canary.pr.4192.1813397076f44a55e5798478e7321db9877de97a", + "graphql15": "npm:graphql@15.x", + "graphql16": "npm:graphql@16.x", + "graphql17": "npm:graphql@17.x", "prettier": "^3.3.3", "typescript": "^5.6.2" }, diff --git a/src/cli.ts b/src/cli.ts index 7d98e3e..beeffa0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,25 +1,9 @@ import { readFile, writeFile } from "node:fs/promises"; import { parseArgs } from "node:util"; -import { - buildSchema, - GraphQLFieldConfig, - GraphQLFieldConfigMap, - GraphQLInterfaceType, - GraphQLList, - GraphQLNamedType, - GraphQLNonNull, - GraphQLObjectType, - GraphQLOutputType, - GraphQLSchema, - GraphQLSemanticNonNull, - GraphQLType, - GraphQLUnionType, - Kind, - printSchema, - validateSchema, -} from "graphql"; -import type { Maybe } from "graphql/jsutils/Maybe"; +import { buildSchema, printSchema, validateSchema } from "graphql"; + +import { semanticToNullable, semanticToStrict } from "./index.js"; export async function main(toStrict = false) { const { @@ -51,203 +35,10 @@ export async function main(toStrict = false) { throw new Error("Invalid schema"); } - const derivedSchema = convertSchema(schema, toStrict); + const derivedSchema = toStrict + ? semanticToStrict(schema) + : semanticToNullable(schema); const newSdl = printSchema(derivedSchema); await writeFile(output, newSdl + "\n"); } - -function convertSchema(schema: GraphQLSchema, toStrict: boolean) { - const config = schema.toConfig(); - const convertType = makeConvertType(toStrict); - const derivedSchema = new GraphQLSchema({ - ...config, - query: convertType(config.query), - mutation: convertType(config.mutation), - subscription: convertType(config.subscription), - types: config.types - .filter((t) => !t.name.startsWith("__")) - .map((t) => convertType(t)), - directives: config.directives.filter((d) => d.name !== "semanticNonNull"), - }); - return derivedSchema; -} - -export function semanticToNullable(schema: GraphQLSchema) { - return convertSchema(schema, false); -} - -export function semanticToStrict(schema: GraphQLSchema) { - return convertSchema(schema, true); -} - -function makeConvertType(toStrict: boolean) { - const cache = new Map(); - - function convertFields(fields: GraphQLFieldConfigMap) { - return () => { - return Object.fromEntries( - Object.entries(fields).map(([fieldName, inSpec]) => { - const spec = applySemanticNonNullDirectiveToFieldConfig(inSpec); - return [ - fieldName, - { - ...spec, - type: convertType(spec.type), - }, - ]; - }), - ) as GraphQLFieldConfigMap; - }; - } - - function convertTypes( - types: readonly GraphQLInterfaceType[] | null | undefined, - ): undefined | (() => readonly GraphQLInterfaceType[]); - function convertTypes( - types: readonly GraphQLObjectType[], - ): () => readonly GraphQLObjectType[]; - function convertTypes( - types: readonly GraphQLNamedType[], - ): undefined | (() => readonly GraphQLNamedType[]); - function convertTypes( - types: readonly GraphQLNamedType[] | undefined, - ): undefined | (() => readonly GraphQLNamedType[]); - function convertTypes( - types: readonly GraphQLNamedType[] | null | undefined, - ): undefined | (() => readonly GraphQLNamedType[]) { - if (!types) { - return undefined; - } - return () => types.map((t) => convertType(t)); - } - - function convertType(type: null | undefined): null | undefined; - function convertType(type: GraphQLObjectType): GraphQLObjectType; - function convertType( - type: Maybe, - ): Maybe; - function convertType(type: GraphQLNamedType): GraphQLNamedType; - function convertType(type: GraphQLType): GraphQLType; - function convertType(type: GraphQLType | null | undefined) { - if (!type) { - return type; - } - if (type instanceof GraphQLSemanticNonNull) { - const unwrapped = convertType(type.ofType); - // Here's where we do our thing! - if (toStrict) { - return new GraphQLNonNull(unwrapped); - } else { - return unwrapped; - } - } else if (type instanceof GraphQLNonNull) { - return new GraphQLNonNull(convertType(type.ofType)); - } else if (type instanceof GraphQLList) { - return new GraphQLList(convertType(type.ofType)); - } - if (type.name.startsWith("__")) { - return null; - } - if (cache.has(type.name)) { - return cache.get(type.name); - } - const newType = (() => { - if (type instanceof GraphQLObjectType) { - const config = type.toConfig(); - return new GraphQLObjectType({ - ...config, - fields: convertFields(config.fields), - interfaces: convertTypes(config.interfaces), - }); - } else if (type instanceof GraphQLInterfaceType) { - const config = type.toConfig(); - return new GraphQLInterfaceType({ - ...config, - fields: convertFields(config.fields), - interfaces: convertTypes(config.interfaces), - }); - } else if (type instanceof GraphQLUnionType) { - const config = type.toConfig(); - return new GraphQLUnionType({ - ...config, - types: convertTypes(config.types), - }); - } else { - return type; - } - })(); - cache.set(type.name, newType); - return newType; - } - - return convertType; -} - -/** - * Takes a GraphQL field config and checks to see if the `@semanticNonNull` - * directive was applied; if so, converts to a field config using explicit - * GraphQLSemanticNonNull wrapper types instead. - * - * @see {@url https://www.apollographql.com/docs/kotlin/advanced/nullability/#semanticnonnull} - */ -export function applySemanticNonNullDirectiveToFieldConfig( - spec: GraphQLFieldConfig, -): GraphQLFieldConfig { - const directive = spec.astNode?.directives?.find( - (d) => d.name.value === "semanticNonNull", - ); - if (!directive) { - return spec; - } - const levelsArg = directive.arguments?.find((a) => a.name.value === "levels"); - const levels = - levelsArg?.value?.kind === Kind.LIST - ? levelsArg.value.values - .filter((v) => v.kind === Kind.INT) - .map((v) => Number(v.value)) - : [0]; - function recurse(type: GraphQLOutputType, level: number): GraphQLOutputType { - if (type instanceof GraphQLSemanticNonNull) { - // Strip semantic-non-null types; this should never happen but if someone - // uses both semantic-non-null and the `@semanticNonNull` directive, we - // want the directive to win (I guess?) - return recurse(type.ofType, level); - } else if (type instanceof GraphQLNonNull) { - const inner = recurse(type.ofType, level); - if (levels.includes(level)) { - // Semantic non-null from `inner` replaces our GrpahQLNonNull wrapper - return inner; - } else { - // Keep non-null wrapper; no semantic-non-null was added to `inner` - return new GraphQLNonNull(inner); - } - } else if (type instanceof GraphQLList) { - const inner = new GraphQLList(recurse(type.ofType, level + 1)); - if (levels.includes(level)) { - return new GraphQLSemanticNonNull(inner); - } else { - return inner; - } - } else { - if (levels.includes(level)) { - return new GraphQLSemanticNonNull(type); - } else { - return type; - } - } - } - - return { - ...spec, - type: recurse(spec.type, 0), - astNode: spec.astNode - ? { - ...spec.astNode, - directives: spec.astNode.directives?.filter( - (d) => d.name.value !== "semanticNonNull", - ), - } - : undefined, - }; -} diff --git a/src/index.ts b/src/index.ts index cf56256..d149e2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,229 @@ -export { - applySemanticNonNullDirectiveToFieldConfig, - semanticToNullable, - semanticToStrict, -} from "./cli.js"; +import { + GraphQLFieldConfig, + GraphQLFieldConfigMap, + GraphQLInterfaceType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, + GraphQLNullableType, + GraphQLObjectType, + GraphQLOutputType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + isInterfaceType, + isListType, + isNonNullType, + isObjectType, + isUnionType, + Kind, +} from "graphql"; +import * as graphql from "graphql"; + +type Maybe = null | undefined | T; + +// If GraphQL doesn't have this helper function, then it doesn't natively support GraphQLSemanticNonNull +const isSemanticNonNullType: ( + t: unknown, +) => t is { ofType: GraphQLNullableType } = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (graphql as any).isSemanticNonNullType ?? (() => false); + +function convertSchema(schema: GraphQLSchema, toStrict: boolean) { + const config = schema.toConfig(); + const convertType = makeConvertType(toStrict); + const derivedSchema = new GraphQLSchema({ + ...config, + query: convertType(config.query), + mutation: convertType(config.mutation), + subscription: convertType(config.subscription), + types: config.types + .filter((t) => !t.name.startsWith("__")) + .map((t) => convertType(t)), + directives: config.directives.filter((d) => d.name !== "semanticNonNull"), + }); + return derivedSchema; +} + +export function semanticToNullable(schema: GraphQLSchema) { + return convertSchema(schema, false); +} + +export function semanticToStrict(schema: GraphQLSchema) { + return convertSchema(schema, true); +} + +function makeConvertType(toStrict: boolean) { + const cache = new Map(); + + function convertFields(fields: GraphQLFieldConfigMap) { + return () => { + return Object.fromEntries( + Object.entries(fields).map(([fieldName, inSpec]) => { + const spec = convertFieldConfig(inSpec, toStrict); + return [ + fieldName, + { + ...spec, + type: convertType(spec.type), + }, + ]; + }), + ) as GraphQLFieldConfigMap; + }; + } + + function convertTypes( + types: readonly GraphQLInterfaceType[] | null | undefined, + ): undefined | (() => readonly GraphQLInterfaceType[]); + function convertTypes( + types: readonly GraphQLObjectType[], + ): () => readonly GraphQLObjectType[]; + function convertTypes( + types: readonly GraphQLNamedType[], + ): undefined | (() => readonly GraphQLNamedType[]); + function convertTypes( + types: readonly GraphQLNamedType[] | undefined, + ): undefined | (() => readonly GraphQLNamedType[]); + function convertTypes( + types: readonly GraphQLNamedType[] | null | undefined, + ): undefined | (() => readonly GraphQLNamedType[]) { + if (!types) { + return undefined; + } + return () => types.map((t) => convertType(t)); + } + + function convertType(type: null | undefined): null | undefined; + function convertType(type: GraphQLObjectType): GraphQLObjectType; + function convertType( + type: Maybe, + ): Maybe; + function convertType(type: GraphQLNamedType): GraphQLNamedType; + function convertType(type: GraphQLType): GraphQLType; + function convertType(type: GraphQLType | null | undefined) { + if (!type) { + return type; + } + if (isSemanticNonNullType(type)) { + const unwrapped = convertType(type.ofType); + // Here's where we do our thing! + if (toStrict) { + return new GraphQLNonNull(unwrapped); + } else { + return unwrapped; + } + } else if (isNonNullType(type)) { + return new GraphQLNonNull( + convertType(type.ofType as GraphQLNullableType), + ); + } else if (isListType(type)) { + return new GraphQLList(convertType(type.ofType as GraphQLType)); + } + if (type.name.startsWith("__")) { + return null; + } + if (cache.has(type.name)) { + return cache.get(type.name); + } + const newType = (() => { + if (isObjectType(type)) { + const config = type.toConfig(); + return new GraphQLObjectType({ + ...config, + fields: convertFields(config.fields), + interfaces: convertTypes(config.interfaces), + }); + } else if (isInterfaceType(type)) { + const config = type.toConfig(); + return new GraphQLInterfaceType({ + ...config, + fields: convertFields(config.fields), + interfaces: convertTypes(config.interfaces), + }); + } else if (isUnionType(type)) { + const config = type.toConfig(); + return new GraphQLUnionType({ + ...config, + types: convertTypes(config.types), + }); + } else { + return type; + } + })(); + cache.set(type.name, newType); + return newType; + } + + return convertType; +} + +/** + * Takes a GraphQL field config and checks to see if the `@semanticNonNull` + * directive was applied; if so, converts to a field config that adds + * GraphQLNonNull wrapper types in the relevant places if `toStrict` is true. + * + * @see {@url https://www.apollographql.com/docs/kotlin/advanced/nullability/#semanticnonnull} + */ +export function convertFieldConfig( + spec: GraphQLFieldConfig, + toStrict: boolean, +): GraphQLFieldConfig { + const directive = spec.astNode?.directives?.find( + (d) => d.name.value === "semanticNonNull", + ); + if (!directive) { + return spec; + } + + /** The AST node with the semanticNonNull directive removed */ + const filteredAstNode = { + ...spec.astNode!, + directives: spec.astNode!.directives!.filter( + (d) => d.name.value !== "semanticNonNull", + ), + }; + + const levelsArg = directive.arguments?.find((a) => a.name.value === "levels"); + const levels = + levelsArg?.value?.kind === Kind.LIST + ? levelsArg.value.values + .filter((v) => v.kind === Kind.INT) + .map((v) => Number(v.value)) + : [0]; + function recurse(type: GraphQLOutputType, level: number): GraphQLOutputType { + if (isSemanticNonNullType(type)) { + // Strip semantic-non-null types; this should never happen but if someone + // uses both semantic-non-null and the `@semanticNonNull` directive, we + // want the directive to win (I guess?) + return recurse(type.ofType, level); + } else if (isNonNullType(type)) { + const inner = recurse(type.ofType, level); + if (isNonNullType(inner)) { + return inner; + } else { + // Carry the non-null through no matter what semantic says + return new GraphQLNonNull(inner); + } + } else if (isListType(type)) { + const inner = new GraphQLList(recurse(type.ofType, level + 1)); + if (toStrict && levels.includes(level)) { + return new GraphQLNonNull(inner); + } else { + return inner; + } + } else { + if (toStrict && levels.includes(level)) { + return new GraphQLNonNull(type); + } else { + return type; + } + } + } + + return { + ...spec, + type: recurse(spec.type, 0), + astNode: filteredAstNode, + }; +} diff --git a/yarn.lock b/yarn.lock index 8bc0006..d63fa90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -918,11 +918,31 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -graphql@16.9.0-canary.pr.4192.1813397076f44a55e5798478e7321db9877de97a: +"graphql-pr-4192@npm:graphql@16.9.0-canary.pr.4192.1813397076f44a55e5798478e7321db9877de97a": version "16.9.0-canary.pr.4192.1813397076f44a55e5798478e7321db9877de97a" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0-canary.pr.4192.1813397076f44a55e5798478e7321db9877de97a.tgz#c5bbcdb258959b98352bcd9ea7f17790647113ae" integrity sha512-P8UYoxSUI1KGr9O5f+AMA3TuLYxOcELoQebxGrnVAIUHM6HCpiLDT+CylrBWEBmvcc7S0xRFRiwvgwzChzLTyQ== +"graphql15@npm:graphql@15.x": + version "15.10.1" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.10.1.tgz#e9ff3bb928749275477f748b14aa5c30dcad6f2f" + integrity sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg== + +"graphql16@npm:graphql@16.x": + version "16.10.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c" + integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ== + +"graphql17@npm:graphql@17.x": + version "17.0.0-alpha.8" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-17.0.0-alpha.8.tgz#dba4a0cbe3efe8243666726f1b4ffd65f87d9b06" + integrity sha512-j9Jn56NCWVaLMt1hSNkMDoCuAisBwY3bxp/5tbrJuPtNtHg9dAf4NjKnlVDCksVP3jBVcipFaEXKWsdNxTlcyg== + +"graphql@15.x | 16.x | 17.x": + version "17.0.0-alpha.8" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-17.0.0-alpha.8.tgz#dba4a0cbe3efe8243666726f1b4ffd65f87d9b06" + integrity sha512-j9Jn56NCWVaLMt1hSNkMDoCuAisBwY3bxp/5tbrJuPtNtHg9dAf4NjKnlVDCksVP3jBVcipFaEXKWsdNxTlcyg== + has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"