From f13b088bb20dfd7a967a1c604838d5feadf26ec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 07:21:46 +0000 Subject: [PATCH 1/2] Add @pothos/plugin-semantic-nullability Plugin that converts non-null fields to nullable with @semanticNonNull directives. Supports per-field opt-in via `semanticNonNull: true` and schema-wide conversion via `allNonNullFields: true` with per-field opt-out. Correctly computes `levels` for nested list types. https://claude.ai/code/session_01MLrx31yBgXYrx1LEzfPs2e --- .../esm/.gitignore | 4 + .../plugin-semantic-nullability/package.json | 56 +++++ .../src/global-types.ts | 34 +++ .../plugin-semantic-nullability/src/index.ts | 145 ++++++++++++ .../tests/__snapshots__/index.test.ts.snap | 22 ++ .../tests/index.test.ts | 211 ++++++++++++++++++ .../plugin-semantic-nullability/tsconfig.json | 12 + .../tsconfig.type.json | 7 + pnpm-lock.yaml | 28 ++- 9 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 packages/plugin-semantic-nullability/esm/.gitignore create mode 100644 packages/plugin-semantic-nullability/package.json create mode 100644 packages/plugin-semantic-nullability/src/global-types.ts create mode 100644 packages/plugin-semantic-nullability/src/index.ts create mode 100644 packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap create mode 100644 packages/plugin-semantic-nullability/tests/index.test.ts create mode 100644 packages/plugin-semantic-nullability/tsconfig.json create mode 100644 packages/plugin-semantic-nullability/tsconfig.type.json diff --git a/packages/plugin-semantic-nullability/esm/.gitignore b/packages/plugin-semantic-nullability/esm/.gitignore new file mode 100644 index 000000000..e4704508f --- /dev/null +++ b/packages/plugin-semantic-nullability/esm/.gitignore @@ -0,0 +1,4 @@ +* +!.gitignore +!.npmignore +!package.json diff --git a/packages/plugin-semantic-nullability/package.json b/packages/plugin-semantic-nullability/package.json new file mode 100644 index 000000000..a1678339e --- /dev/null +++ b/packages/plugin-semantic-nullability/package.json @@ -0,0 +1,56 @@ +{ + "name": "@pothos/plugin-semantic-nullability", + "version": "0.1.0", + "description": "A Pothos plugin that converts non-null fields to nullable with @semanticNonNull directives", + "main": "./lib/index.js", + "types": "./dts/index.d.ts", + "module": "./esm/index.js", + "exports": { + "import": { + "default": "./esm/index.js" + }, + "require": { + "types": "./dts/index.d.ts", + "default": "./lib/index.js" + } + }, + "scripts": { + "type": "tsc --project tsconfig.type.json", + "build": "pnpm build:clean && pnpm build:cjs && pnpm build:dts && pnpm build:esm", + "build:clean": "git clean -dfX esm lib", + "build:cjs": "swc src -d lib --config-file ../../.swcrc -C module.type=commonjs --strip-leading-paths", + "build:esm": "cp -r dts/* esm/ && swc src -d esm --config-file ../../.swcrc -C module.type=es6 --strip-leading-paths && pnpm esm:extensions", + "build:dts": "tsc", + "esm:extensions": "TS_NODE_PROJECT=../../tsconfig.json node -r @swc-node/register ../../scripts/esm-transformer.ts", + "test": "pnpm vitest --run" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hayes/pothos.git", + "directory": "packages/plugin-semantic-nullability" + }, + "author": "Michael Hayes", + "license": "ISC", + "keywords": [ + "pothos", + "graphql", + "schema", + "typescript", + "semantic-nullability", + "semanticNonNull" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "@pothos/core": "*", + "graphql": "^16.10.0" + }, + "devDependencies": { + "@pothos/core": "workspace:*", + "@pothos/test-utils": "workspace:*", + "graphql-tag": "^2.12.6" + }, + "gitHead": "9dfe52f1975f41a111e01bf96a20033a914e2acc" +} diff --git a/packages/plugin-semantic-nullability/src/global-types.ts b/packages/plugin-semantic-nullability/src/global-types.ts new file mode 100644 index 000000000..0b8f2b262 --- /dev/null +++ b/packages/plugin-semantic-nullability/src/global-types.ts @@ -0,0 +1,34 @@ +import type { + FieldNullability, + InputFieldMap, + SchemaTypes, + TypeParam, +} from '@pothos/core'; +import type { PothosSemanticNullabilityPlugin } from '.'; + +declare global { + export namespace PothosSchemaTypes { + export interface Plugins { + semanticNullability: PothosSemanticNullabilityPlugin; + } + + export interface SchemaBuilderOptions { + semanticNullability?: { + /** When true, all non-null output fields are converted to nullable with @semanticNonNull */ + allNonNullFields?: boolean; + }; + } + + export interface FieldOptions< + Types extends SchemaTypes = SchemaTypes, + ParentShape = unknown, + Type extends TypeParam = TypeParam, + Nullable extends FieldNullability = FieldNullability, + Args extends InputFieldMap = InputFieldMap, + ResolveShape = unknown, + ResolveReturnShape = unknown, + > { + semanticNonNull?: boolean; + } + } +} diff --git a/packages/plugin-semantic-nullability/src/index.ts b/packages/plugin-semantic-nullability/src/index.ts new file mode 100644 index 000000000..e34765d4c --- /dev/null +++ b/packages/plugin-semantic-nullability/src/index.ts @@ -0,0 +1,145 @@ +import './global-types'; +import SchemaBuilder, { + BasePlugin, + type PothosOutputFieldConfig, + type PothosOutputFieldType, + type SchemaTypes, +} from '@pothos/core'; +import { + DirectiveLocation, + GraphQLDirective, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + type GraphQLSchema, +} from 'graphql'; + +const pluginName = 'semanticNullability'; + +export default pluginName; + +function collectNonNullLevels( + type: PothosOutputFieldType, + level = 0, +): number[] { + const levels: number[] = []; + + if (!type.nullable) { + levels.push(level); + } + + if (type.kind === 'List') { + levels.push(...collectNonNullLevels(type.type, level + 1)); + } + + return levels; +} + +function makeNullable( + type: PothosOutputFieldType, +): PothosOutputFieldType { + if (type.kind === 'List') { + return { + ...type, + nullable: true, + type: makeNullable(type.type), + }; + } + + return { + ...type, + nullable: true, + }; +} + +export class PothosSemanticNullabilityPlugin< + Types extends SchemaTypes, +> extends BasePlugin { + override onOutputFieldConfig( + fieldConfig: PothosOutputFieldConfig, + ): PothosOutputFieldConfig { + const fieldOption = fieldConfig.pothosOptions.semanticNonNull; + const allNonNullFields = + this.builder.options.semanticNullability?.allNonNullFields ?? false; + + // Per-field option takes priority over the schema-wide default. + const enabled = fieldOption ?? allNonNullFields; + + if (!enabled) { + return fieldConfig; + } + + // Collect which levels are non-null before we modify anything + const levels = collectNonNullLevels(fieldConfig.type); + + // Nothing to convert if already fully nullable + if (levels.length === 0) { + return fieldConfig; + } + + // Make all levels nullable and attach the directive. + // Omit levels arg when it's just [0] (the default). + const directiveArgs = levels.length === 1 && levels[0] === 0 ? {} : { levels }; + + return { + ...fieldConfig, + type: makeNullable(fieldConfig.type), + extensions: { + ...fieldConfig.extensions, + directives: mergeDirective( + fieldConfig.extensions?.directives as DirectiveList | undefined, + directiveArgs, + ), + }, + }; + } + + override afterBuild(schema: GraphQLSchema) { + // Add the @semanticNonNull directive definition to the schema if not already present + const existing = schema.getDirectives(); + const hasDirective = existing.some((d) => d.name === 'semanticNonNull'); + + if (!hasDirective) { + const directives = [ + ...existing, + new GraphQLDirective({ + name: 'semanticNonNull', + locations: [DirectiveLocation.FIELD_DEFINITION], + args: { + levels: { + type: new GraphQLList(new GraphQLNonNull(GraphQLInt)), + defaultValue: [0], + }, + }, + }), + ]; + + // GraphQLSchema stores directives as a readonly array set during construction, + // but we need to add our directive after the schema is built. + Object.defineProperty(schema, '_directives', { value: directives }); + } + + return schema; + } +} + +type DirectiveList = Array<{ name: string; args: Record }>; + +function mergeDirective( + existing: DirectiveList | undefined, + directiveArgs: { levels?: number[] }, +): DirectiveList { + const existingDirectives = existing ?? []; + + return [ + ...(Array.isArray(existingDirectives) + ? existingDirectives + : Object.keys(existingDirectives).map((name) => ({ + name, + args: (existingDirectives as unknown as Record>)[name], + }))), + { name: 'semanticNonNull', args: directiveArgs }, + ]; +} + +SchemaBuilder.registerPlugin(pluginName, PothosSemanticNullabilityPlugin); diff --git a/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap b/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap new file mode 100644 index 000000000..792ecfbdb --- /dev/null +++ b/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap @@ -0,0 +1,22 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`plugin-semantic-nullability > per-field opt-in > generates expected schema 1`] = ` +"directive @semanticNonNull(levels: [Int!] = [0]) on FIELD_DEFINITION + +type Query { + age: Int! + bio: String + name: String + tags: [String] +}" +`; + +exports[`plugin-semantic-nullability > schema-wide allNonNullFields > generates expected schema 1`] = ` +"directive @semanticNonNull(levels: [Int!] = [0]) on FIELD_DEFINITION + +type Query { + age: Int! + bio: String + name: String +}" +`; diff --git a/packages/plugin-semantic-nullability/tests/index.test.ts b/packages/plugin-semantic-nullability/tests/index.test.ts new file mode 100644 index 000000000..2a3ff0bee --- /dev/null +++ b/packages/plugin-semantic-nullability/tests/index.test.ts @@ -0,0 +1,211 @@ +import SchemaBuilder from '@pothos/core'; +import { type GraphQLObjectType, printSchema } from 'graphql'; +import '../src'; + +describe('plugin-semantic-nullability', () => { + describe('per-field opt-in', () => { + const builder = new SchemaBuilder({ + plugins: ['semanticNullability'], + }); + + builder.queryType({ + fields: (t) => ({ + name: t.string({ + nullable: false, + semanticNonNull: true, + resolve: () => 'hello', + }), + age: t.int({ + nullable: false, + resolve: () => 42, + }), + bio: t.string({ + nullable: true, + semanticNonNull: true, + resolve: () => null, + }), + tags: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: true, + resolve: () => ['a', 'b'], + }), + }), + }); + + const schema = builder.toSchema(); + const queryType = schema.getType('Query') as GraphQLObjectType; + + it('converts non-null field with semanticNonNull to nullable', () => { + const nameField = queryType.getFields().name; + // Should be nullable (no NonNull wrapper) + expect(nameField.type.toString()).toBe('String'); + }); + + it('leaves non-null field without semanticNonNull as non-null', () => { + const ageField = queryType.getFields().age; + expect(ageField.type.toString()).toBe('Int!'); + }); + + it('leaves already-nullable field unchanged', () => { + const bioField = queryType.getFields().bio; + expect(bioField.type.toString()).toBe('String'); + }); + + it('converts non-null list field with semanticNonNull to nullable', () => { + const tagsField = queryType.getFields().tags; + // Both list and items should be nullable + expect(tagsField.type.toString()).toBe('[String]'); + }); + + it('adds @semanticNonNull directive to extensions for non-null field', () => { + const nameField = queryType.getFields().name; + const directives = nameField.extensions?.directives as Array<{ + name: string; + args: Record; + }>; + expect(directives).toContainEqual({ + name: 'semanticNonNull', + args: {}, + }); + }); + + it('adds @semanticNonNull with levels for list field', () => { + const tagsField = queryType.getFields().tags; + const directives = tagsField.extensions?.directives as Array<{ + name: string; + args: Record; + }>; + expect(directives).toContainEqual({ + name: 'semanticNonNull', + args: { levels: [0, 1] }, + }); + }); + + it('does not add directive to already-nullable field', () => { + const bioField = queryType.getFields().bio; + const directives = bioField.extensions?.directives as Array<{ + name: string; + args: Record; + }>; + // Should have no directives since the field was already nullable + expect(directives).toBeUndefined(); + }); + + it('registers the @semanticNonNull directive definition', () => { + const directive = schema.getDirective('semanticNonNull'); + expect(directive).toBeDefined(); + expect(directive?.name).toBe('semanticNonNull'); + }); + + it('generates expected schema', () => { + expect(printSchema(schema)).toMatchSnapshot(); + }); + }); + + describe('schema-wide allNonNullFields', () => { + const builder = new SchemaBuilder({ + plugins: ['semanticNullability'], + semanticNullability: { + allNonNullFields: true, + }, + }); + + builder.queryType({ + fields: (t) => ({ + name: t.string({ + nullable: false, + resolve: () => 'hello', + }), + bio: t.string({ + nullable: true, + resolve: () => null, + }), + age: t.int({ + nullable: false, + semanticNonNull: false, + resolve: () => 42, + }), + }), + }); + + const schema = builder.toSchema(); + const queryType = schema.getType('Query') as GraphQLObjectType; + + it('converts all non-null fields by default', () => { + const nameField = queryType.getFields().name; + expect(nameField.type.toString()).toBe('String'); + const directives = nameField.extensions?.directives as Array<{ + name: string; + args: Record; + }>; + expect(directives).toContainEqual({ + name: 'semanticNonNull', + args: {}, + }); + }); + + it('leaves nullable fields unchanged', () => { + const bioField = queryType.getFields().bio; + expect(bioField.type.toString()).toBe('String'); + }); + + it('respects per-field opt-out with semanticNonNull: false', () => { + const ageField = queryType.getFields().age; + expect(ageField.type.toString()).toBe('Int!'); + }); + + it('generates expected schema', () => { + expect(printSchema(schema)).toMatchSnapshot(); + }); + }); + + describe('list with partial nullability', () => { + const builder = new SchemaBuilder({ + plugins: ['semanticNullability'], + }); + + builder.queryType({ + fields: (t) => ({ + listNullableItems: t.stringList({ + nullable: { list: false, items: true }, + semanticNonNull: true, + resolve: () => ['a', null], + }), + nullableListNonNullItems: t.stringList({ + nullable: { list: true, items: false }, + semanticNonNull: true, + resolve: () => ['a'], + }), + }), + }); + + const schema = builder.toSchema(); + const queryType = schema.getType('Query') as GraphQLObjectType; + + it('converts list with non-null list, nullable items - only level 0', () => { + const field = queryType.getFields().listNullableItems; + expect(field.type.toString()).toBe('[String]'); + const directives = field.extensions?.directives as Array<{ + name: string; + args: Record; + }>; + expect(directives).toContainEqual({ + name: 'semanticNonNull', + args: {}, + }); + }); + + it('converts list with nullable list, non-null items - only level 1', () => { + const field = queryType.getFields().nullableListNonNullItems; + expect(field.type.toString()).toBe('[String]'); + const directives = field.extensions?.directives as Array<{ + name: string; + args: Record; + }>; + expect(directives).toContainEqual({ + name: 'semanticNonNull', + args: { levels: [1] }, + }); + }); + }); +}); diff --git a/packages/plugin-semantic-nullability/tsconfig.json b/packages/plugin-semantic-nullability/tsconfig.json new file mode 100644 index 000000000..62e9bc339 --- /dev/null +++ b/packages/plugin-semantic-nullability/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "dts", + "rootDir": "src" + }, + "include": ["src/**/*"], + "extends": "../../tsconfig.options.json" +} diff --git a/packages/plugin-semantic-nullability/tsconfig.type.json b/packages/plugin-semantic-nullability/tsconfig.type.json new file mode 100644 index 000000000..ba0eac9d4 --- /dev/null +++ b/packages/plugin-semantic-nullability/tsconfig.type.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "skipLibCheck": true + }, + "extends": "../../tsconfig.options.json", + "include": ["src/**/*", "tests/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbc613e8c..4d70aba99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1061,6 +1061,22 @@ importers: specifier: ^7.1.0 version: 7.1.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + packages/plugin-semantic-nullability: + dependencies: + graphql: + specifier: ^16.10.0 + version: 16.12.0 + devDependencies: + '@pothos/core': + specifier: workspace:* + version: link:../core + '@pothos/test-utils': + specifier: workspace:* + version: link:../test-utils + graphql-tag: + specifier: ^2.12.6 + version: 2.12.6(graphql@16.12.0) + packages/plugin-simple-objects: dependencies: graphql: @@ -13163,7 +13179,7 @@ snapshots: '@graphql-tools/utils': 8.9.0(graphql@16.12.0) dataloader: 2.1.0 graphql: 16.12.0 - tslib: 2.4.1 + tslib: 2.8.1 value-or-promise: 1.0.11 '@graphql-tools/batch-execute@9.0.19(graphql@16.12.0)': @@ -13224,7 +13240,7 @@ snapshots: dependencies: graphql: 16.12.0 lodash.sortby: 4.7.0 - tslib: 2.6.3 + tslib: 2.8.1 '@graphql-tools/executor-common@0.0.4(graphql@16.12.0)': dependencies: @@ -13421,14 +13437,14 @@ snapshots: '@graphql-tools/optimize@2.0.0(graphql@16.12.0)': dependencies: graphql: 16.12.0 - tslib: 2.6.3 + tslib: 2.8.1 '@graphql-tools/relay-operation-optimizer@7.0.26(encoding@0.1.13)(graphql@16.12.0)': dependencies: '@ardatan/relay-compiler': 12.0.3(encoding@0.1.13)(graphql@16.12.0) '@graphql-tools/utils': 10.11.0(graphql@16.12.0) graphql: 16.12.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -17172,7 +17188,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.2.0 + tslib: 2.8.1 caniuse-lite@1.0.30001759: {} @@ -21195,7 +21211,7 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.2.0 + tslib: 2.8.1 path-browserify@0.0.1: {} From 4b450b0595e869b14d4826d1ddfd4de4094d83ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 07:28:24 +0000 Subject: [PATCH 2/2] Fix semanticNonNull to check nullability and handle list depth - `true` now only converts level 0 (the field itself), not all levels - Accept `number[]` to specify explicit levels (e.g. `[0, 1]`) - Only convert levels that are actually non-null (skip already-nullable) - Nullable fields with `semanticNonNull: true` are correctly ignored https://claude.ai/code/session_01MLrx31yBgXYrx1LEzfPs2e --- .../src/global-types.ts | 10 +- .../plugin-semantic-nullability/src/index.ts | 81 +++--- .../tests/__snapshots__/index.test.ts.snap | 22 +- .../tests/index.test.ts | 236 ++++++++++-------- 4 files changed, 214 insertions(+), 135 deletions(-) diff --git a/packages/plugin-semantic-nullability/src/global-types.ts b/packages/plugin-semantic-nullability/src/global-types.ts index 0b8f2b262..2fdefaae5 100644 --- a/packages/plugin-semantic-nullability/src/global-types.ts +++ b/packages/plugin-semantic-nullability/src/global-types.ts @@ -14,7 +14,7 @@ declare global { export interface SchemaBuilderOptions { semanticNullability?: { - /** When true, all non-null output fields are converted to nullable with @semanticNonNull */ + /** When true, all non-null output fields get @semanticNonNull at level 0 */ allNonNullFields?: boolean; }; } @@ -28,7 +28,13 @@ declare global { ResolveShape = unknown, ResolveReturnShape = unknown, > { - semanticNonNull?: boolean; + /** + * Convert non-null positions to nullable with @semanticNonNull directive. + * - `true`: applies to level 0 only (the field itself) + * - `number[]`: applies to specific levels (e.g. [0, 1] for list + items) + * - `false`: opt out when allNonNullFields is enabled + */ + semanticNonNull?: boolean | number[]; } } } diff --git a/packages/plugin-semantic-nullability/src/index.ts b/packages/plugin-semantic-nullability/src/index.ts index e34765d4c..a21e3da3b 100644 --- a/packages/plugin-semantic-nullability/src/index.ts +++ b/packages/plugin-semantic-nullability/src/index.ts @@ -18,38 +18,61 @@ const pluginName = 'semanticNullability'; export default pluginName; -function collectNonNullLevels( +function makeNullableAtLevels( type: PothosOutputFieldType, - level = 0, -): number[] { - const levels: number[] = []; + levels: Set, + current = 0, +): PothosOutputFieldType { + const shouldConvert = levels.has(current) && !type.nullable; - if (!type.nullable) { - levels.push(level); + if (type.kind === 'List') { + return { + ...type, + nullable: shouldConvert ? true : type.nullable, + type: makeNullableAtLevels(type.type, levels, current + 1), + }; } - if (type.kind === 'List') { - levels.push(...collectNonNullLevels(type.type, level + 1)); + return { + ...type, + nullable: shouldConvert ? true : type.nullable, + }; +} + +function resolveOption( + fieldOption: boolean | number[] | undefined, + allNonNullFields: boolean, +): number[] | null { + // Per-field option takes priority over the schema-wide default. + const option = fieldOption ?? (allNonNullFields ? true : false); + + if (option === false) { + return null; } - return levels; + if (option === true) { + return [0]; + } + + return option; } -function makeNullable( +function filterNonNullLevels( type: PothosOutputFieldType, -): PothosOutputFieldType { + requestedLevels: number[], + current = 0, +): number[] { + const levels: number[] = []; + + if (requestedLevels.includes(current) && !type.nullable) { + levels.push(current); + } + if (type.kind === 'List') { - return { - ...type, - nullable: true, - type: makeNullable(type.type), - }; + levels.push(...filterNonNullLevels(type.type, requestedLevels, current + 1)); } - return { - ...type, - nullable: true, - }; + return levels; } export class PothosSemanticNullabilityPlugin< @@ -62,28 +85,25 @@ export class PothosSemanticNullabilityPlugin< const allNonNullFields = this.builder.options.semanticNullability?.allNonNullFields ?? false; - // Per-field option takes priority over the schema-wide default. - const enabled = fieldOption ?? allNonNullFields; + const requestedLevels = resolveOption(fieldOption, allNonNullFields); - if (!enabled) { + if (!requestedLevels) { return fieldConfig; } - // Collect which levels are non-null before we modify anything - const levels = collectNonNullLevels(fieldConfig.type); + // Only convert levels that are actually non-null + const levels = filterNonNullLevels(fieldConfig.type, requestedLevels); - // Nothing to convert if already fully nullable if (levels.length === 0) { return fieldConfig; } - // Make all levels nullable and attach the directive. - // Omit levels arg when it's just [0] (the default). + // Omit levels arg when it's just [0] (the directive default) const directiveArgs = levels.length === 1 && levels[0] === 0 ? {} : { levels }; return { ...fieldConfig, - type: makeNullable(fieldConfig.type), + type: makeNullableAtLevels(fieldConfig.type, new Set(levels)), extensions: { ...fieldConfig.extensions, directives: mergeDirective( @@ -95,7 +115,6 @@ export class PothosSemanticNullabilityPlugin< } override afterBuild(schema: GraphQLSchema) { - // Add the @semanticNonNull directive definition to the schema if not already present const existing = schema.getDirectives(); const hasDirective = existing.some((d) => d.name === 'semanticNonNull'); @@ -114,8 +133,6 @@ export class PothosSemanticNullabilityPlugin< }), ]; - // GraphQLSchema stores directives as a readonly array set during construction, - // but we need to add our directive after the schema is built. Object.defineProperty(schema, '_directives', { value: directives }); } diff --git a/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap b/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap index 792ecfbdb..d620caf7f 100644 --- a/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap +++ b/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap @@ -1,5 +1,25 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`plugin-semantic-nullability > explicit levels for lists > generates expected schema 1`] = ` +"directive @semanticNonNull(levels: [Int!] = [0]) on FIELD_DEFINITION + +type Query { + bothLevels: [String] + itemsOnly: [String]! + listOnly: [String!] +}" +`; + +exports[`plugin-semantic-nullability > list fields with true (level 0 only) > generates expected schema 1`] = ` +"directive @semanticNonNull(levels: [Int!] = [0]) on FIELD_DEFINITION + +type Query { + nullableItems: [String] + nullableList: [String!] + tags: [String!] +}" +`; + exports[`plugin-semantic-nullability > per-field opt-in > generates expected schema 1`] = ` "directive @semanticNonNull(levels: [Int!] = [0]) on FIELD_DEFINITION @@ -7,7 +27,6 @@ type Query { age: Int! bio: String name: String - tags: [String] }" `; @@ -18,5 +37,6 @@ type Query { age: Int! bio: String name: String + tags: [String!] }" `; diff --git a/packages/plugin-semantic-nullability/tests/index.test.ts b/packages/plugin-semantic-nullability/tests/index.test.ts index 2a3ff0bee..fcaeea7b0 100644 --- a/packages/plugin-semantic-nullability/tests/index.test.ts +++ b/packages/plugin-semantic-nullability/tests/index.test.ts @@ -24,11 +24,6 @@ describe('plugin-semantic-nullability', () => { semanticNonNull: true, resolve: () => null, }), - tags: t.stringList({ - nullable: { list: false, items: false }, - semanticNonNull: true, - resolve: () => ['a', 'b'], - }), }), }); @@ -36,61 +31,31 @@ describe('plugin-semantic-nullability', () => { const queryType = schema.getType('Query') as GraphQLObjectType; it('converts non-null field with semanticNonNull to nullable', () => { - const nameField = queryType.getFields().name; - // Should be nullable (no NonNull wrapper) - expect(nameField.type.toString()).toBe('String'); + const field = queryType.getFields().name; + expect(field.type.toString()).toBe('String'); }); it('leaves non-null field without semanticNonNull as non-null', () => { - const ageField = queryType.getFields().age; - expect(ageField.type.toString()).toBe('Int!'); - }); - - it('leaves already-nullable field unchanged', () => { - const bioField = queryType.getFields().bio; - expect(bioField.type.toString()).toBe('String'); + const field = queryType.getFields().age; + expect(field.type.toString()).toBe('Int!'); }); - it('converts non-null list field with semanticNonNull to nullable', () => { - const tagsField = queryType.getFields().tags; - // Both list and items should be nullable - expect(tagsField.type.toString()).toBe('[String]'); + it('leaves already-nullable field unchanged even with semanticNonNull', () => { + const field = queryType.getFields().bio; + expect(field.type.toString()).toBe('String'); + // No directive since there are no non-null levels to convert + expect(field.extensions?.directives).toBeUndefined(); }); - it('adds @semanticNonNull directive to extensions for non-null field', () => { - const nameField = queryType.getFields().name; - const directives = nameField.extensions?.directives as Array<{ - name: string; - args: Record; - }>; + it('adds @semanticNonNull directive to extensions', () => { + const field = queryType.getFields().name; + const directives = field.extensions?.directives as DirectiveList; expect(directives).toContainEqual({ name: 'semanticNonNull', args: {}, }); }); - it('adds @semanticNonNull with levels for list field', () => { - const tagsField = queryType.getFields().tags; - const directives = tagsField.extensions?.directives as Array<{ - name: string; - args: Record; - }>; - expect(directives).toContainEqual({ - name: 'semanticNonNull', - args: { levels: [0, 1] }, - }); - }); - - it('does not add directive to already-nullable field', () => { - const bioField = queryType.getFields().bio; - const directives = bioField.extensions?.directives as Array<{ - name: string; - args: Record; - }>; - // Should have no directives since the field was already nullable - expect(directives).toBeUndefined(); - }); - it('registers the @semanticNonNull directive definition', () => { const directive = schema.getDirective('semanticNonNull'); expect(directive).toBeDefined(); @@ -102,28 +67,27 @@ describe('plugin-semantic-nullability', () => { }); }); - describe('schema-wide allNonNullFields', () => { + describe('list fields with true (level 0 only)', () => { const builder = new SchemaBuilder({ plugins: ['semanticNullability'], - semanticNullability: { - allNonNullFields: true, - }, }); builder.queryType({ fields: (t) => ({ - name: t.string({ - nullable: false, - resolve: () => 'hello', + tags: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: true, + resolve: () => ['a', 'b'], }), - bio: t.string({ - nullable: true, - resolve: () => null, + nullableItems: t.stringList({ + nullable: { list: false, items: true }, + semanticNonNull: true, + resolve: () => ['a', null], }), - age: t.int({ - nullable: false, - semanticNonNull: false, - resolve: () => 42, + nullableList: t.stringList({ + nullable: { list: true, items: false }, + semanticNonNull: true, + resolve: () => ['a'], }), }), }); @@ -131,27 +95,24 @@ describe('plugin-semantic-nullability', () => { const schema = builder.toSchema(); const queryType = schema.getType('Query') as GraphQLObjectType; - it('converts all non-null fields by default', () => { - const nameField = queryType.getFields().name; - expect(nameField.type.toString()).toBe('String'); - const directives = nameField.extensions?.directives as Array<{ - name: string; - args: Record; - }>; - expect(directives).toContainEqual({ - name: 'semanticNonNull', - args: {}, - }); + it('only converts level 0 for [String!]! — items stay non-null', () => { + const field = queryType.getFields().tags; + expect(field.type.toString()).toBe('[String!]'); + const directives = field.extensions?.directives as DirectiveList; + expect(directives).toContainEqual({ name: 'semanticNonNull', args: {} }); }); - it('leaves nullable fields unchanged', () => { - const bioField = queryType.getFields().bio; - expect(bioField.type.toString()).toBe('String'); + it('converts level 0 for [String]! — items already nullable', () => { + const field = queryType.getFields().nullableItems; + expect(field.type.toString()).toBe('[String]'); + const directives = field.extensions?.directives as DirectiveList; + expect(directives).toContainEqual({ name: 'semanticNonNull', args: {} }); }); - it('respects per-field opt-out with semanticNonNull: false', () => { - const ageField = queryType.getFields().age; - expect(ageField.type.toString()).toBe('Int!'); + it('skips already-nullable level 0 for [String!]', () => { + const field = queryType.getFields().nullableList; + expect(field.type.toString()).toBe('[String!]'); + expect(field.extensions?.directives).toBeUndefined(); }); it('generates expected schema', () => { @@ -159,21 +120,26 @@ describe('plugin-semantic-nullability', () => { }); }); - describe('list with partial nullability', () => { + describe('explicit levels for lists', () => { const builder = new SchemaBuilder({ plugins: ['semanticNullability'], }); builder.queryType({ fields: (t) => ({ - listNullableItems: t.stringList({ - nullable: { list: false, items: true }, - semanticNonNull: true, - resolve: () => ['a', null], + bothLevels: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: [0, 1], + resolve: () => ['a'], }), - nullableListNonNullItems: t.stringList({ - nullable: { list: true, items: false }, - semanticNonNull: true, + itemsOnly: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: [1], + resolve: () => ['a'], + }), + listOnly: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: [0], resolve: () => ['a'], }), }), @@ -182,30 +148,100 @@ describe('plugin-semantic-nullability', () => { const schema = builder.toSchema(); const queryType = schema.getType('Query') as GraphQLObjectType; - it('converts list with non-null list, nullable items - only level 0', () => { - const field = queryType.getFields().listNullableItems; + it('converts both levels with [0, 1]', () => { + const field = queryType.getFields().bothLevels; expect(field.type.toString()).toBe('[String]'); - const directives = field.extensions?.directives as Array<{ - name: string; - args: Record; - }>; + const directives = field.extensions?.directives as DirectiveList; expect(directives).toContainEqual({ name: 'semanticNonNull', - args: {}, + args: { levels: [0, 1] }, }); }); - it('converts list with nullable list, non-null items - only level 1', () => { - const field = queryType.getFields().nullableListNonNullItems; - expect(field.type.toString()).toBe('[String]'); - const directives = field.extensions?.directives as Array<{ - name: string; - args: Record; - }>; + it('converts only items with [1] — list stays non-null', () => { + const field = queryType.getFields().itemsOnly; + expect(field.type.toString()).toBe('[String]!'); + const directives = field.extensions?.directives as DirectiveList; expect(directives).toContainEqual({ name: 'semanticNonNull', args: { levels: [1] }, }); }); + + it('converts only list with [0] — items stay non-null', () => { + const field = queryType.getFields().listOnly; + expect(field.type.toString()).toBe('[String!]'); + const directives = field.extensions?.directives as DirectiveList; + expect(directives).toContainEqual({ name: 'semanticNonNull', args: {} }); + }); + + it('generates expected schema', () => { + expect(printSchema(schema)).toMatchSnapshot(); + }); + }); + + describe('schema-wide allNonNullFields', () => { + const builder = new SchemaBuilder({ + plugins: ['semanticNullability'], + semanticNullability: { + allNonNullFields: true, + }, + }); + + builder.queryType({ + fields: (t) => ({ + name: t.string({ + nullable: false, + resolve: () => 'hello', + }), + bio: t.string({ + nullable: true, + resolve: () => null, + }), + age: t.int({ + nullable: false, + semanticNonNull: false, + resolve: () => 42, + }), + tags: t.stringList({ + nullable: { list: false, items: false }, + resolve: () => ['a'], + }), + }), + }); + + const schema = builder.toSchema(); + const queryType = schema.getType('Query') as GraphQLObjectType; + + it('converts non-null fields at level 0 by default', () => { + const field = queryType.getFields().name; + expect(field.type.toString()).toBe('String'); + const directives = field.extensions?.directives as DirectiveList; + expect(directives).toContainEqual({ name: 'semanticNonNull', args: {} }); + }); + + it('leaves nullable fields unchanged', () => { + const field = queryType.getFields().bio; + expect(field.type.toString()).toBe('String'); + expect(field.extensions?.directives).toBeUndefined(); + }); + + it('respects per-field opt-out with semanticNonNull: false', () => { + const field = queryType.getFields().age; + expect(field.type.toString()).toBe('Int!'); + }); + + it('converts list at level 0 only — items stay non-null', () => { + const field = queryType.getFields().tags; + expect(field.type.toString()).toBe('[String!]'); + const directives = field.extensions?.directives as DirectiveList; + expect(directives).toContainEqual({ name: 'semanticNonNull', args: {} }); + }); + + it('generates expected schema', () => { + expect(printSchema(schema)).toMatchSnapshot(); + }); }); }); + +type DirectiveList = Array<{ name: string; args: Record }>;