Skip to content

Commit cec32ea

Browse files
committed
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
1 parent 4143153 commit cec32ea

File tree

9 files changed

+513
-6
lines changed

9 files changed

+513
-6
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*
2+
!.gitignore
3+
!.npmignore
4+
!package.json
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@pothos/plugin-semantic-nullability",
3+
"version": "0.1.0",
4+
"description": "A Pothos plugin that converts non-null fields to nullable with @semanticNonNull directives",
5+
"main": "./lib/index.js",
6+
"types": "./dts/index.d.ts",
7+
"module": "./esm/index.js",
8+
"exports": {
9+
"import": {
10+
"default": "./esm/index.js"
11+
},
12+
"require": {
13+
"types": "./dts/index.d.ts",
14+
"default": "./lib/index.js"
15+
}
16+
},
17+
"scripts": {
18+
"type": "tsc --project tsconfig.type.json",
19+
"build": "pnpm build:clean && pnpm build:cjs && pnpm build:dts && pnpm build:esm",
20+
"build:clean": "git clean -dfX esm lib",
21+
"build:cjs": "swc src -d lib --config-file ../../.swcrc -C module.type=commonjs --strip-leading-paths",
22+
"build:esm": "cp -r dts/* esm/ && swc src -d esm --config-file ../../.swcrc -C module.type=es6 --strip-leading-paths && pnpm esm:extensions",
23+
"build:dts": "tsc",
24+
"esm:extensions": "TS_NODE_PROJECT=../../tsconfig.json node -r @swc-node/register ../../scripts/esm-transformer.ts",
25+
"test": "pnpm vitest --run"
26+
},
27+
"repository": {
28+
"type": "git",
29+
"url": "git+https://github.com/hayes/pothos.git",
30+
"directory": "packages/plugin-semantic-nullability"
31+
},
32+
"author": "Michael Hayes",
33+
"license": "ISC",
34+
"keywords": [
35+
"pothos",
36+
"graphql",
37+
"schema",
38+
"typescript",
39+
"semantic-nullability",
40+
"semanticNonNull"
41+
],
42+
"publishConfig": {
43+
"access": "public",
44+
"provenance": true
45+
},
46+
"peerDependencies": {
47+
"@pothos/core": "*",
48+
"graphql": "^16.10.0"
49+
},
50+
"devDependencies": {
51+
"@pothos/core": "workspace:*",
52+
"@pothos/test-utils": "workspace:*",
53+
"graphql-tag": "^2.12.6"
54+
},
55+
"gitHead": "9dfe52f1975f41a111e01bf96a20033a914e2acc"
56+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type {
2+
FieldNullability,
3+
InputFieldMap,
4+
SchemaTypes,
5+
TypeParam,
6+
} from '@pothos/core';
7+
import type { PothosSemanticNullabilityPlugin } from '.';
8+
9+
declare global {
10+
export namespace PothosSchemaTypes {
11+
export interface Plugins<Types extends SchemaTypes> {
12+
semanticNullability: PothosSemanticNullabilityPlugin<Types>;
13+
}
14+
15+
export interface SchemaBuilderOptions<Types extends SchemaTypes> {
16+
semanticNullability?: {
17+
/** When true, all non-null output fields are converted to nullable with @semanticNonNull */
18+
allNonNullFields?: boolean;
19+
};
20+
}
21+
22+
export interface FieldOptions<
23+
Types extends SchemaTypes = SchemaTypes,
24+
ParentShape = unknown,
25+
Type extends TypeParam<Types> = TypeParam<Types>,
26+
Nullable extends FieldNullability<Type> = FieldNullability<Type>,
27+
Args extends InputFieldMap = InputFieldMap,
28+
ResolveShape = unknown,
29+
ResolveReturnShape = unknown,
30+
> {
31+
semanticNonNull?: boolean;
32+
}
33+
}
34+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import './global-types';
2+
import SchemaBuilder, {
3+
BasePlugin,
4+
type PothosOutputFieldConfig,
5+
type PothosOutputFieldType,
6+
type SchemaTypes,
7+
} from '@pothos/core';
8+
import {
9+
DirectiveLocation,
10+
GraphQLDirective,
11+
GraphQLInt,
12+
GraphQLList,
13+
GraphQLNonNull,
14+
type GraphQLSchema,
15+
} from 'graphql';
16+
17+
const pluginName = 'semanticNullability';
18+
19+
export default pluginName;
20+
21+
function collectNonNullLevels<Types extends SchemaTypes>(
22+
type: PothosOutputFieldType<Types>,
23+
level = 0,
24+
): number[] {
25+
const levels: number[] = [];
26+
27+
if (!type.nullable) {
28+
levels.push(level);
29+
}
30+
31+
if (type.kind === 'List') {
32+
levels.push(...collectNonNullLevels(type.type, level + 1));
33+
}
34+
35+
return levels;
36+
}
37+
38+
function makeNullable<Types extends SchemaTypes>(
39+
type: PothosOutputFieldType<Types>,
40+
): PothosOutputFieldType<Types> {
41+
if (type.kind === 'List') {
42+
return {
43+
...type,
44+
nullable: true,
45+
type: makeNullable(type.type),
46+
};
47+
}
48+
49+
return {
50+
...type,
51+
nullable: true,
52+
};
53+
}
54+
55+
export class PothosSemanticNullabilityPlugin<
56+
Types extends SchemaTypes,
57+
> extends BasePlugin<Types> {
58+
override onOutputFieldConfig(
59+
fieldConfig: PothosOutputFieldConfig<Types>,
60+
): PothosOutputFieldConfig<Types> {
61+
const fieldOption = fieldConfig.pothosOptions.semanticNonNull;
62+
const allNonNullFields =
63+
this.builder.options.semanticNullability?.allNonNullFields ?? false;
64+
65+
// Per-field option takes priority over the schema-wide default.
66+
const enabled = fieldOption ?? allNonNullFields;
67+
68+
if (!enabled) {
69+
return fieldConfig;
70+
}
71+
72+
// Collect which levels are non-null before we modify anything
73+
const levels = collectNonNullLevels(fieldConfig.type);
74+
75+
// Nothing to convert if already fully nullable
76+
if (levels.length === 0) {
77+
return fieldConfig;
78+
}
79+
80+
// Make all levels nullable and attach the directive.
81+
// Omit levels arg when it's just [0] (the default).
82+
const directiveArgs = levels.length === 1 && levels[0] === 0 ? {} : { levels };
83+
84+
return {
85+
...fieldConfig,
86+
type: makeNullable(fieldConfig.type),
87+
extensions: {
88+
...fieldConfig.extensions,
89+
directives: mergeDirective(
90+
fieldConfig.extensions?.directives as DirectiveList | undefined,
91+
directiveArgs,
92+
),
93+
},
94+
};
95+
}
96+
97+
override afterBuild(schema: GraphQLSchema) {
98+
// Add the @semanticNonNull directive definition to the schema if not already present
99+
const existing = schema.getDirectives();
100+
const hasDirective = existing.some((d) => d.name === 'semanticNonNull');
101+
102+
if (!hasDirective) {
103+
const directives = [
104+
...existing,
105+
new GraphQLDirective({
106+
name: 'semanticNonNull',
107+
locations: [DirectiveLocation.FIELD_DEFINITION],
108+
args: {
109+
levels: {
110+
type: new GraphQLList(new GraphQLNonNull(GraphQLInt)),
111+
defaultValue: [0],
112+
},
113+
},
114+
}),
115+
];
116+
117+
// GraphQLSchema stores directives as a readonly array set during construction,
118+
// but we need to add our directive after the schema is built.
119+
Object.defineProperty(schema, '_directives', { value: directives });
120+
}
121+
122+
return schema;
123+
}
124+
}
125+
126+
type DirectiveList = Array<{ name: string; args: Record<string, unknown> }>;
127+
128+
function mergeDirective(
129+
existing: DirectiveList | undefined,
130+
directiveArgs: { levels?: number[] },
131+
): DirectiveList {
132+
const existingDirectives = existing ?? [];
133+
134+
return [
135+
...(Array.isArray(existingDirectives)
136+
? existingDirectives
137+
: Object.keys(existingDirectives).map((name) => ({
138+
name,
139+
args: (existingDirectives as unknown as Record<string, Record<string, unknown>>)[name],
140+
}))),
141+
{ name: 'semanticNonNull', args: directiveArgs },
142+
];
143+
}
144+
145+
SchemaBuilder.registerPlugin(pluginName, PothosSemanticNullabilityPlugin);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`plugin-semantic-nullability > per-field opt-in > generates expected schema 1`] = `
4+
"directive @semanticNonNull(levels: [Int!] = [0]) on FIELD_DEFINITION
5+
6+
type Query {
7+
age: Int!
8+
bio: String
9+
name: String
10+
tags: [String]
11+
}"
12+
`;
13+
14+
exports[`plugin-semantic-nullability > schema-wide allNonNullFields > generates expected schema 1`] = `
15+
"directive @semanticNonNull(levels: [Int!] = [0]) on FIELD_DEFINITION
16+
17+
type Query {
18+
age: Int!
19+
bio: String
20+
name: String
21+
}"
22+
`;

0 commit comments

Comments
 (0)