Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/plugin-semantic-nullability/esm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
*
!.gitignore
!.npmignore
!package.json
56 changes: 56 additions & 0 deletions packages/plugin-semantic-nullability/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
40 changes: 40 additions & 0 deletions packages/plugin-semantic-nullability/src/global-types.ts
Original file line number Diff line number Diff line change
@@ -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<Types extends SchemaTypes> {
semanticNullability: PothosSemanticNullabilityPlugin<Types>;
}

export interface SchemaBuilderOptions<Types extends SchemaTypes> {
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<Types> = TypeParam<Types>,
Nullable extends FieldNullability<Type> = FieldNullability<Type>,
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[];
}
}
}
162 changes: 162 additions & 0 deletions packages/plugin-semantic-nullability/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<Types extends SchemaTypes>(
type: PothosOutputFieldType<Types>,
levels: Set<number>,
current = 0,
): PothosOutputFieldType<Types> {
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<Types extends SchemaTypes>(
type: PothosOutputFieldType<Types>,
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<Types> {
override onOutputFieldConfig(
fieldConfig: PothosOutputFieldConfig<Types>,
): PothosOutputFieldConfig<Types> {
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<string, unknown> }>;

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<string, Record<string, unknown>>)[name],
}))),
{ name: 'semanticNonNull', args: directiveArgs },
];
}

SchemaBuilder.registerPlugin(pluginName, PothosSemanticNullabilityPlugin);
Original file line number Diff line number Diff line change
@@ -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!]
}"
`;
Loading
Loading