Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add withScalar method #914

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
10 changes: 10 additions & 0 deletions .changeset/three-rules-fail.md
@@ -0,0 +1,10 @@
---
'@pothos/core': minor
---

Add `withScalar` method to the schema builder to allow inference of Scalar typescript types from
`GraphQLScalarType` scalars

```typescript
const builder = new SchemaBuilder({}).withScalars({ Date: CustomDateScalar });
```
31 changes: 29 additions & 2 deletions packages/core/src/builder.ts
Expand Up @@ -28,6 +28,7 @@ import InterfaceRef, { ImplementableInterfaceRef } from './refs/interface';
import ObjectRef, { ImplementableObjectRef } from './refs/object';
import ScalarRef from './refs/scalar';
import UnionRef from './refs/union';
import { toPairs } from './toPairs';
hayes marked this conversation as resolved.
Show resolved Hide resolved
import type {
AbstractReturnShape,
BaseEnum,
Expand Down Expand Up @@ -584,8 +585,8 @@ export default class SchemaBuilder<Types extends SchemaTypes> {
const [options = {}] = args;
const { directives, extensions } = options;

const scalars = [GraphQLID, GraphQLInt, GraphQLFloat, GraphQLString, GraphQLBoolean];
scalars.forEach((scalar) => {
const builtInScalars = [GraphQLID, GraphQLInt, GraphQLFloat, GraphQLString, GraphQLBoolean];
builtInScalars.forEach((scalar) => {
if (!this.configStore.hasConfig(scalar.name as OutputType<Types>)) {
this.addScalarType(scalar.name as ScalarName<Types>, scalar, {});
}
Expand Down Expand Up @@ -614,4 +615,30 @@ export default class SchemaBuilder<Types extends SchemaTypes> {
? processedSchema
: lexicographicSortSchema(processedSchema);
}

withScalars<const ScalarRecord extends Record<string, GraphQLScalarType>>(scalars: ScalarRecord) {
const that = this as unknown as SchemaBuilder<
hayes marked this conversation as resolved.
Show resolved Hide resolved
PothosSchemaTypes.ExtendDefaultTypes<
hayes marked this conversation as resolved.
Show resolved Hide resolved
Types & {
hayes marked this conversation as resolved.
Show resolved Hide resolved
Scalars: {
// Extract the Input and Output types from GraphQLScalarType's generics
[Property in keyof ScalarRecord]: ScalarRecord[Property] extends GraphQLScalarType<
infer TInternal,
infer TExternal
>
? {
Input: TInternal;
Output: TExternal;
hayes marked this conversation as resolved.
Show resolved Hide resolved
}
: never;
};
}
>
>;

for (const [name, scalar] of toPairs(scalars)) {
hayes marked this conversation as resolved.
Show resolved Hide resolved
that.addScalarType(name, scalar, {});
Copy link
Owner

@hayes hayes May 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's a bit awkward and inconvenient, but it's pretty important to be able to provide these options.

This could be Date: [DateResolver, options] or Date: { type/resolver/implementation: DateResolver, ...otherOptions }

Personally I like the second option (although the resolver terminology from graphql-scalars is not my favorite.

I'd probably make it work so you can pass either just the scalar, or scalar + options. Inference will be slightly more complex but shouldn't be too bad.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working on this, might take a bit of time

I am aiming to have the following be valid.

const builder = new SchemaBuilder({}).withScalars({
  Date: DateScalar,
  UUID: { scalar: UUIDScalar },
  PositiveInt: {
    scalar: PositiveIntScalar,
    options: {
      extensions: { codegenScalarType: "number" },
    },
  },
});

Does that look good to you?

}
return that;
}
}
11 changes: 11 additions & 0 deletions packages/core/src/toPairs.ts
@@ -0,0 +1,11 @@
// Taken from remeda https://github.com/remeda/remeda/blob/1082bc6ffa4711c162426af7028279a0a267272a/src/toPairs.ts
hayes marked this conversation as resolved.
Show resolved Hide resolved
type ObjectKeys<T extends object> = `${Exclude<keyof T, symbol>}`;
type ObjectValues<T extends Record<PropertyKey, unknown>> = Required<T>[ObjectKeys<T>];
type ObjectEntry<T extends Record<PropertyKey, unknown>> = [ObjectKeys<T>, ObjectValues<T>];
type ObjectEntries<T extends Record<PropertyKey, unknown>> = ObjectEntry<T>[];

export function toPairs<T extends Record<PropertyKey, unknown>>(object: T): ObjectEntries<T> {
// @ts-expect-error [ts2322] - This is deliberately stricter than what TS
// provides out of the box.
return Object.entries(object);
}
312 changes: 312 additions & 0 deletions packages/core/tests/scalars.test.ts
@@ -0,0 +1,312 @@
import { execute, GraphQLScalarType } from 'graphql';
import { DateTimeResolver as ogDateTimeResolver, NonNegativeIntResolver } from 'graphql-scalars';
import gql from 'graphql-tag';
import SchemaBuilder from '../src';

// Add generic types while waiting for https://github.com/Urigo/graphql-scalars/pull/1920
const DateTimeResolver = ogDateTimeResolver as GraphQLScalarType<Date, Date>;

const PositiveIntResolver = new GraphQLScalarType({
name: 'PositiveInt',
serialize: (n) => n as number,
parseValue: (n) => {
if (typeof n !== 'number') {
throw new TypeError('Value must be a number');
}

if (n >= 0) {
return n;
}

throw new TypeError('Value must be positive');
},
});

enum Diet {
HERBIVOROUS,
CARNIVOROUS,
OMNIVORIOUS,
}

class Animal {
diet: Diet;

constructor(diet: Diet) {
this.diet = diet;
}
}

class Kiwi extends Animal {
name: string;
birthday: Date;
heightInMeters: number;

constructor(name: string, birthday: Date, heightInMeters: number) {
super(Diet.HERBIVOROUS);

this.name = name;
this.birthday = birthday;
this.heightInMeters = heightInMeters;
}
}

describe('scalars', () => {
it('when a scalar is added withScalars, the scalartype is added', () => {
const builder = new SchemaBuilder({}).withScalars({ PositiveInt: PositiveIntResolver });
builder.queryType();
builder.queryFields((t) => ({
positiveInt: t.field({
type: 'PositiveInt',
args: { v: t.arg.int({ required: true }) },
resolve: (_root, args) => args.v,
}),
}));

const schema = builder.toSchema();
expect(() =>
execute({
schema,
document: gql`query { positiveInt("hello") }`,
}),
).toThrow('Expected Name, found String "hello"');
});

it('when scalars are added using withScalars, the Objects from the user schema are kept', async () => {
const builder = new SchemaBuilder<{
Objects: {
Example: { n: number };
};
}>({}).withScalars({
PositiveInt: PositiveIntResolver,
});

const Example = builder.objectType('Example', {
fields: (t) => ({
n: t.expose('n', {
type: 'PositiveInt',
}),
}),
});

builder.queryType({
fields: (t) => ({
example: t.field({
type: Example,
resolve: () => ({ n: 1 }),
}),
}),
});
const schema = builder.toSchema();

const result = await execute({
schema,
document: gql`
query {
example {
n
}
}
`,
});
expect(result.data).toEqual({ example: { n: 1 } });
});

it('when scalars are added using withScalars, the Interfaces from the user schema are kept', async () => {
const builder = new SchemaBuilder<{
Interfaces: {
Animal: Animal;
};
}>({}).withScalars({
NonNegativeInt: NonNegativeIntResolver,
});

builder.enumType(Diet, { name: 'Diet' });

builder.interfaceType('Animal', {
fields: (t) => ({
diet: t.expose('diet', {
exampleRequiredOptionFromPlugin: true,
type: Diet,
}),
}),
});

builder.objectType(Kiwi, {
name: 'Kiwi',
interfaces: ['Animal'],
Liam-Tait marked this conversation as resolved.
Show resolved Hide resolved
isTypeOf: (value) => value instanceof Kiwi,
description: 'Long necks, cool patterns, taller than you.',
hayes marked this conversation as resolved.
Show resolved Hide resolved
fields: (t) => ({
name: t.exposeString('name', {}),
age: t.int({
resolve: (parent) => 5, // hard coded so test don't break over time
}),
}),
});

builder.queryType({
fields: (t) => ({
kiwi: t.field({
type: 'Animal',
resolve: () => new Kiwi('TV Kiwi', new Date(Date.UTC(1975, 0, 1)), 0.5),
}),
}),
});

const schema = builder.toSchema();
const result = await execute({
schema,
document: gql`
query {
kiwi {
name
age
heightInMeters
}
}
`,
});
expect(result.data).toEqual({ kiwi: { name: 'TV Kiwi', age: 5 } });
});
it('when scalars are added using withScalars, the Context from the user schema are kept', async () => {
const builder = new SchemaBuilder<{
Context: { name: string };
}>({}).withScalars({
NonNegativeInt: NonNegativeIntResolver,
});

builder.queryType({
fields: (t) => ({
name: t.field({
type: 'String',
resolve: (_root, _args, context) => context.name,
}),
}),
});

const schema = builder.toSchema();
const result = await execute({
schema,
document: gql`
query {
name
}
`,
contextValue: { name: 'Hello' },
});
expect(result.data).toEqual({ name: 'Hello' });
});

it('when scalars are added using withScalars, the DefaultFieldNullability from the user schema are kept', async () => {
const builder = new SchemaBuilder<{
DefaultFieldNullability: true;
}>({
defaultFieldNullability: true,
}).withScalars({
NonNegativeInt: NonNegativeIntResolver,
});

builder.queryType({
fields: (t) => ({
name: t.field({
type: 'String',
resolve: () => null,
}),
}),
});

const schema = builder.toSchema();
const result = await execute({
schema,
document: gql`
query {
name
}
`,
});
expect(result.data).toEqual({ name: null });
});

it('when scalars are added using withScalars, the DefaultInputFieldRequiredness from the user schema are kept', async () => {
const builder = new SchemaBuilder<{
DefaultInputFieldRequiredness: true;
}>({
defaultInputFieldRequiredness: true,
}).withScalars({
NonNegativeInt: NonNegativeIntResolver,
});

builder.queryType({
fields: (t) => ({
example: t.field({
type: 'Int',
args: {
v: t.arg.int(),
},
// Would be a type error here if didn't work
resolve: (_root, args) => args.v,
}),
}),
});

const schema = builder.toSchema();
const result = await execute({
schema,
document: gql`
query {
example(v: 3)
}
`,
});
expect(result.data).toEqual({ example: 3 });
});

it('when scalars are added withScalars, scalars can still be manually typed', () => {
const builder = new SchemaBuilder<{
Scalars: {
PositiveInt: { Input: number; Output: number };
};
}>({}).withScalars({ DateTime: DateTimeResolver });

builder.addScalarType('PositiveInt', PositiveIntResolver, {});

builder.objectRef<{}>('Example').implement({
fields: (t) => ({
// Manual typing
positiveInt: t.field({
type: 'PositiveInt',
resolve: () => 1,
}),
// Inferred
datetime: t.field({
type: 'DateTime',
resolve: () => new Date(),
}),
}),
});

expect(builder).toBeDefined();
});

it('when the scalar has internal types the, scalar types are infered are possible', () => {
const builder = new SchemaBuilder({}).withScalars({
DateTime: DateTimeResolver,
PositiveInt: PositiveIntResolver,
});

builder.objectRef<{}>('Example').implement({
fields: (t) => ({
positiveInt: t.field({
type: 'PositiveInt',
resolve: () => 1,
}),
datetime: t.field({
type: 'DateTime',
resolve: () => new Date(),
}),
}),
});

expect(builder).toBeDefined();
});
});