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..2fdefaae5 --- /dev/null +++ b/packages/plugin-semantic-nullability/src/global-types.ts @@ -0,0 +1,40 @@ +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 get @semanticNonNull at level 0 */ + 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, + > { + /** + * 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 new file mode 100644 index 000000000..a21e3da3b --- /dev/null +++ b/packages/plugin-semantic-nullability/src/index.ts @@ -0,0 +1,162 @@ +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 makeNullableAtLevels( + type: PothosOutputFieldType, + levels: Set, + current = 0, +): PothosOutputFieldType { + const shouldConvert = levels.has(current) && !type.nullable; + + if (type.kind === 'List') { + return { + ...type, + nullable: shouldConvert ? true : type.nullable, + type: makeNullableAtLevels(type.type, levels, current + 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; + } + + if (option === true) { + return [0]; + } + + return option; +} + +function filterNonNullLevels( + type: PothosOutputFieldType, + requestedLevels: number[], + current = 0, +): number[] { + const levels: number[] = []; + + if (requestedLevels.includes(current) && !type.nullable) { + levels.push(current); + } + + if (type.kind === 'List') { + levels.push(...filterNonNullLevels(type.type, requestedLevels, current + 1)); + } + + return levels; +} + +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; + + const requestedLevels = resolveOption(fieldOption, allNonNullFields); + + if (!requestedLevels) { + return fieldConfig; + } + + // Only convert levels that are actually non-null + const levels = filterNonNullLevels(fieldConfig.type, requestedLevels); + + if (levels.length === 0) { + return fieldConfig; + } + + // Omit levels arg when it's just [0] (the directive default) + const directiveArgs = levels.length === 1 && levels[0] === 0 ? {} : { levels }; + + return { + ...fieldConfig, + type: makeNullableAtLevels(fieldConfig.type, new Set(levels)), + extensions: { + ...fieldConfig.extensions, + directives: mergeDirective( + fieldConfig.extensions?.directives as DirectiveList | undefined, + directiveArgs, + ), + }, + }; + } + + override afterBuild(schema: GraphQLSchema) { + 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], + }, + }, + }), + ]; + + 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..d620caf7f --- /dev/null +++ b/packages/plugin-semantic-nullability/tests/__snapshots__/index.test.ts.snap @@ -0,0 +1,42 @@ +// 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 + +type Query { + age: Int! + bio: String + name: 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 + tags: [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..fcaeea7b0 --- /dev/null +++ b/packages/plugin-semantic-nullability/tests/index.test.ts @@ -0,0 +1,247 @@ +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, + }), + }), + }); + + const schema = builder.toSchema(); + const queryType = schema.getType('Query') as GraphQLObjectType; + + it('converts non-null field with semanticNonNull to nullable', () => { + const field = queryType.getFields().name; + expect(field.type.toString()).toBe('String'); + }); + + it('leaves non-null field without semanticNonNull as non-null', () => { + const field = queryType.getFields().age; + expect(field.type.toString()).toBe('Int!'); + }); + + 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', () => { + const field = queryType.getFields().name; + const directives = field.extensions?.directives as DirectiveList; + expect(directives).toContainEqual({ + name: 'semanticNonNull', + args: {}, + }); + }); + + 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('list fields with true (level 0 only)', () => { + const builder = new SchemaBuilder({ + plugins: ['semanticNullability'], + }); + + builder.queryType({ + fields: (t) => ({ + tags: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: true, + resolve: () => ['a', 'b'], + }), + nullableItems: t.stringList({ + nullable: { list: false, items: true }, + semanticNonNull: true, + resolve: () => ['a', null], + }), + nullableList: t.stringList({ + nullable: { list: true, items: false }, + semanticNonNull: true, + resolve: () => ['a'], + }), + }), + }); + + const schema = builder.toSchema(); + const queryType = schema.getType('Query') as GraphQLObjectType; + + 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('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('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', () => { + expect(printSchema(schema)).toMatchSnapshot(); + }); + }); + + describe('explicit levels for lists', () => { + const builder = new SchemaBuilder({ + plugins: ['semanticNullability'], + }); + + builder.queryType({ + fields: (t) => ({ + bothLevels: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: [0, 1], + resolve: () => ['a'], + }), + itemsOnly: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: [1], + resolve: () => ['a'], + }), + listOnly: t.stringList({ + nullable: { list: false, items: false }, + semanticNonNull: [0], + resolve: () => ['a'], + }), + }), + }); + + const schema = builder.toSchema(); + const queryType = schema.getType('Query') as GraphQLObjectType; + + it('converts both levels with [0, 1]', () => { + const field = queryType.getFields().bothLevels; + expect(field.type.toString()).toBe('[String]'); + const directives = field.extensions?.directives as DirectiveList; + expect(directives).toContainEqual({ + name: 'semanticNonNull', + args: { levels: [0, 1] }, + }); + }); + + 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 }>; 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: {}