From 80e050ce0a80d4a1f3435005c470ae3345b55e0f Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 27 Apr 2024 11:35:41 -0400 Subject: [PATCH] feat(ts-client): returnMode successData (#804) --- src/Schema/core/Index.ts | 8 + src/client/Config.ts | 58 ++++- src/client/RootTypeMethods.ts | 16 +- src/client/SelectionSet/_.ts | 2 +- .../toGraphQLDocumentString.test.ts.snap | 19 ++ .../toGraphQLDocumentString.test.ts | 15 +- .../SelectionSet/toGraphQLDocumentString.ts | 225 ++++++++++++++++-- src/client/client.document.test.ts | 2 + src/client/client.input.test-d.ts | 12 +- src/client/client.returnMode.test-d.ts | 102 ++++++-- src/client/client.returnMode.test.ts | 118 ++++++--- src/client/client.ts | 127 +++++++--- src/client/customScalars.ts | 39 +-- src/client/document.ts | 59 ++++- .../__snapshots__/files.test.ts.snap | 24 ++ src/generator/code/Index.ts | 22 +- src/generator/code/SchemaRuntime.ts | 19 ++ src/generator/code/generateCode.ts | 10 + src/lib/prelude.ts | 6 +- tests/_/db.ts | 5 + tests/_/schema/generated/Index.ts | 12 + tests/_/schema/generated/SchemaRuntime.ts | 12 + tests/_/schemaMutationOnly/generated/Index.ts | 7 + .../generated/SchemaRuntime.ts | 7 + tests/_/schemaQueryOnly/generated/Index.ts | 7 + .../generated/SchemaRuntime.ts | 7 + 26 files changed, 780 insertions(+), 160 deletions(-) diff --git a/src/Schema/core/Index.ts b/src/Schema/core/Index.ts index d45bffc27..aa56bd5bb 100644 --- a/src/Schema/core/Index.ts +++ b/src/Schema/core/Index.ts @@ -1,6 +1,8 @@ +import type { GlobalRegistry } from '../../globalRegistry.js' import type { Output } from '../Output/__.js' export interface Index { + name: GlobalRegistry.SchemaNames Root: { Query: null | Output.Object$2 Mutation: null | Output.Object$2 @@ -11,5 +13,11 @@ export interface Index { interfaces: Record error: { objects: Record + objectsTypename: Record + rootResultFields: { + Query: Record + Mutation: Record + Subscription: Record + } } } diff --git a/src/client/Config.ts b/src/client/Config.ts index 9a97a3114..fbfc8b9e0 100644 --- a/src/client/Config.ts +++ b/src/client/Config.ts @@ -1,22 +1,24 @@ import type { ExecutionResult } from 'graphql' +import type { GlobalRegistry } from '../globalRegistry.js' import type { GraphQLExecutionResultError } from '../lib/graphql.js' import type { SetProperty } from '../lib/prelude.js' +import type { Schema } from '../Schema/__.js' export type ReturnModeType = | ReturnModeTypeGraphQL + | ReturnModeTypeSuccessData | ReturnModeTypeData - | ReturnModeTypeDataAndSchemaErrors - | ReturnModeTypeDataAllErrors + | ReturnModeTypeDataAndErrors -export type ReturnModeTypeBase = ReturnModeTypeGraphQL | ReturnModeTypeData | ReturnModeTypeDataAllErrors +export type ReturnModeTypeBase = ReturnModeTypeGraphQL | ReturnModeTypeDataAndErrors | ReturnModeTypeData export type ReturnModeTypeGraphQL = 'graphql' export type ReturnModeTypeData = 'data' -export type ReturnModeTypeDataAllErrors = 'dataAndAllErrors' +export type ReturnModeTypeDataAndErrors = 'dataAndErrors' -export type ReturnModeTypeDataAndSchemaErrors = 'dataAndSchemaErrors' +export type ReturnModeTypeSuccessData = 'successData' export type OptionsInput = { returnMode: ReturnModeType | undefined @@ -36,10 +38,52 @@ export type ApplyInputDefaults = { } // dprint-ignore -export type ReturnMode<$Config extends Config, $Data, $DataRaw = undefined> = +export type ReturnModeRootType<$Config extends Config, $Index extends Schema.Index, $Data extends object> = + $Config['returnMode'] extends 'graphql' ? ExecutionResult<$Data> : + $Config['returnMode'] extends 'data' ? $Data : + $Config['returnMode'] extends 'successData' ? { [$Key in keyof $Data]: ExcludeSchemaErrors<$Index, $Data[$Key]> } : + $Data | GraphQLExecutionResultError + +// dprint-ignore +export type ReturnModeRootField<$Config extends Config, $Index extends Schema.Index, $Data, $DataRaw = undefined> = $Config['returnMode'] extends 'graphql' ? ExecutionResult<$DataRaw extends undefined ? $Data : $DataRaw> : $Config['returnMode'] extends 'data' ? $Data : + $Config['returnMode'] extends 'successData' ? ExcludeSchemaErrors<$Index, $Data> : $Data | GraphQLExecutionResultError +export type ExcludeSchemaErrors<$Index extends Schema.Index, $Data> = Exclude< + $Data, + $Index['error']['objectsTypename'][keyof $Index['error']['objectsTypename']] +> + export type OrThrowifyConfig<$Config extends Config> = $Config['returnMode'] extends 'graphql' ? $Config - : SetProperty<$Config, 'returnMode', 'data'> + : SetProperty<$Config, 'returnMode', 'successData'> + +/** + * We inject __typename select when: + * 1. using schema errors + * 2. using return mode successData + */ + +type TypenameSelection = { __typename: true } + +// dprint-ignore +export type CreateSelectionTypename<$Config extends Config, $Index extends Schema.Index> = + IsNeedSelectionTypename<$Config, $Index> extends true ? TypenameSelection : {} // eslint-disable-line + +// dprint-ignore +export type IsNeedSelectionTypename<$Config extends Config, $Index extends Schema.Index> = + $Config['returnMode'] extends 'successData' ? GlobalRegistry.HasSchemaErrors<$Index['name']> extends true ? true : + false : + false +export type AugmentRootTypeSelectionWithTypename< + $Config extends Config, + $Index extends Schema.Index, + $RootTypeName extends Schema.RootTypeName, + $Selection extends object, +> = IsNeedSelectionTypename<$Config, $Index> extends true ? { + [$Key in keyof $Selection]: + & $Selection[$Key] + & ($Key extends keyof $Index['error']['rootResultFields'][$RootTypeName] ? TypenameSelection : {}) // eslint-disable-line + } + : $Selection diff --git a/src/client/RootTypeMethods.ts b/src/client/RootTypeMethods.ts index 1033c2457..f9ee3678a 100644 --- a/src/client/RootTypeMethods.ts +++ b/src/client/RootTypeMethods.ts @@ -2,7 +2,14 @@ import type { OperationName } from '../lib/graphql.js' import type { Exact } from '../lib/prelude.js' import type { TSError } from '../lib/TSError.js' import type { InputFieldsAllNullable, Schema } from '../Schema/__.js' -import type { Config, OrThrowifyConfig, ReturnMode } from './Config.js' +import type { + AugmentRootTypeSelectionWithTypename, + Config, + CreateSelectionTypename, + OrThrowifyConfig, + ReturnModeRootField, + ReturnModeRootType, +} from './Config.js' import type { ResultSet } from './ResultSet/__.js' import type { SelectionSet } from './SelectionSet/__.js' @@ -54,10 +61,9 @@ export type RootTypeMethods<$Config extends Config, $Index extends Schema.Index, // dprint-ignore type RootMethod<$Config extends Config, $Index extends Schema.Index, $RootTypeName extends Schema.RootTypeName> = <$SelectionSet extends object>(selectionSet: Exact<$SelectionSet, SelectionSet.Root<$Index, $RootTypeName>>) => - Promise>> + Promise, $Index, $RootTypeName>>> // dprint-ignore -// type RootTypeFieldMethod<$Config extends OptionsInputDefaults, $Index extends Schema.Index, $RootTypeName extends Schema.RootTypeName, $RootTypeFieldName extends string> = type RootTypeFieldMethod<$Context extends RootTypeFieldContext> = RootTypeFieldMethod_<$Context, $Context['Field']['type']> @@ -73,7 +79,7 @@ type RootTypeFieldMethod_<$Context extends RootTypeFieldContext, $Type extends S // dprint-ignore type ObjectLikeFieldMethod<$Context extends RootTypeFieldContext> = <$SelectionSet>(selectionSet: Exact<$SelectionSet, SelectionSet.Field<$Context['Field'], $Context['Index'], { hideDirectives: true }>>) => - Promise>> + Promise, $Context['Field'], $Context['Index']>>> // dprint-ignore type ScalarFieldMethod<$Context extends RootTypeFieldContext> = @@ -82,4 +88,4 @@ type ScalarFieldMethod<$Context extends RootTypeFieldContext> = (() => Promise>>) // dprint-ignore type ReturnModeForFieldMethod<$Context extends RootTypeFieldContext, $Data> = - ReturnMode<$Context['Config'], $Data, { [k in $Context['RootTypeFieldName']] : $Data }> + ReturnModeRootField<$Context['Config'], $Context['Index'], $Data, { [k in $Context['RootTypeFieldName']] : $Data }> diff --git a/src/client/SelectionSet/_.ts b/src/client/SelectionSet/_.ts index fadcf1c32..81516ae41 100644 --- a/src/client/SelectionSet/_.ts +++ b/src/client/SelectionSet/_.ts @@ -1,2 +1,2 @@ export * from './SelectionSet.js' -export * from './toGraphQLDocumentString.js' +export * as Print from './toGraphQLDocumentString.js' diff --git a/src/client/SelectionSet/__snapshots__/toGraphQLDocumentString.test.ts.snap b/src/client/SelectionSet/__snapshots__/toGraphQLDocumentString.test.ts.snap index 3e0876ce3..7d06d9cb2 100644 --- a/src/client/SelectionSet/__snapshots__/toGraphQLDocumentString.test.ts.snap +++ b/src/client/SelectionSet/__snapshots__/toGraphQLDocumentString.test.ts.snap @@ -669,6 +669,25 @@ exports[`args > Query 4`] = ` " `; +exports[`enum > Query 1`] = ` +" +{ + "result": { + "$": { + "case": "Object1" + }, + "__typename": true + } +} +-------------- +{ + result(case: Object1) { + __typename + } +} +" +`; + exports[`other > Query 1`] = ` " { diff --git a/src/client/SelectionSet/toGraphQLDocumentString.test.ts b/src/client/SelectionSet/toGraphQLDocumentString.test.ts index ee2e77fa4..9ccfe77af 100644 --- a/src/client/SelectionSet/toGraphQLDocumentString.test.ts +++ b/src/client/SelectionSet/toGraphQLDocumentString.test.ts @@ -1,15 +1,18 @@ import { parse, print } from 'graphql' import { describe, expect, test } from 'vitest' import type { Index } from '../../../tests/_/schema/generated/Index.js' +import { $Index as schemaIndex } from '../../../tests/_/schema/generated/SchemaRuntime.js' import type { SelectionSet } from './__.js' -import { toGraphQLDocumentSelectionSet } from './toGraphQLDocumentString.js' +import type { Context } from './toGraphQLDocumentString.js' +import { rootTypeSelectionSet } from './toGraphQLDocumentString.js' // eslint-disable-next-line // @ts-ignore type Q = SelectionSet.Query const s = (selectionSet: Q) => selectionSet const prepareResult = (ss: Q) => { - const graphqlDocumentString = toGraphQLDocumentSelectionSet(ss as any) + const context: Context = { schemaIndex, config: { returnMode: `data` } } + const graphqlDocumentString = rootTypeSelectionSet(context, schemaIndex[`Root`][`Query`], ss as any) // Should parse, ensures is syntactically valid graphql document. const document = parse(graphqlDocumentString) const graphqlDocumentStringFormatted = print(document) @@ -21,6 +24,14 @@ const prepareResult = (ss: Q) => { return beforeAfter } +describe(`enum`, () => { + test.each([ + s({ result: { $: { case: `Object1` }, __typename: true } }), + ])(`Query`, (ss) => { + expect(prepareResult(ss)).toMatchSnapshot() + }) +}) + describe(`union`, () => { test.each([ s({ unionFooBar: { __typename: true } }), diff --git a/src/client/SelectionSet/toGraphQLDocumentString.ts b/src/client/SelectionSet/toGraphQLDocumentString.ts index cf77e9084..65258d4f0 100644 --- a/src/client/SelectionSet/toGraphQLDocumentString.ts +++ b/src/client/SelectionSet/toGraphQLDocumentString.ts @@ -1,3 +1,8 @@ +import { RootTypeName } from '../../lib/graphql.js' +import { lowerCaseFirstLetter } from '../../lib/prelude.js' +import { Schema } from '../../Schema/__.js' +import { readMaybeThunk } from '../../Schema/core/helpers.js' +import type { ReturnModeType } from '../Config.js' import type { SelectionSet } from './__.js' import { aliasPattern, fragmentPattern } from './SelectionSet.js' @@ -27,14 +32,22 @@ export type SS = { [k: string]: Indicator | SS } & SpecialFields -// export const toGraphQLDocumentString = (ss: GraphQLDocumentObject) => { -// return `query ${toGraphQLDocumentSelectionSet(ss)}` -// } +export interface Context { + schemaIndex: Schema.Index + config: { + returnMode: ReturnModeType + } +} -export const toGraphQLDocumentSelectionSet = (ss: GraphQLObjectSelection) => { - return `{ - ${selectionSet(ss)} - }` +export const rootTypeSelectionSet = ( + context: Context, + schemaObject: Schema.Object$2, + ss: GraphQLObjectSelection, + name?: string, +) => { + return `${lowerCaseFirstLetter(schemaObject.fields.__typename.type.type)} ${name ?? ``} { ${ + selectionSet(context, schemaObject, ss) + } }` } const directiveArgs = (config: object) => { @@ -43,13 +56,10 @@ const directiveArgs = (config: object) => { }).join(`, `) } -const indicatorOrSelectionSet = (ss: null | Indicator | SS): string => { - if (ss === null) return `null` // todo test this case +const resolveDirectives = (ss: Indicator | SS) => { if (isIndicator(ss)) return `` + const { $include, $skip, $defer, $stream } = ss - const { $include, $skip, $defer, $stream, $, ...rest } = ss - - let args = `` let directives = `` if ($stream !== undefined) { @@ -79,30 +89,171 @@ const indicatorOrSelectionSet = (ss: null | Indicator | SS): string => { directives += `@skip(if: ${String(typeof $skip === `boolean` ? $skip : $skip.if === undefined ? true : $skip.if)})` } + return directives +} + +const resolveArgs = (schemaField: Schema.SomeField, ss: Indicator | SS) => { + if (isIndicator(ss)) return `` + const { $ } = ss + let args = `` if ($ !== undefined) { + const schemaArgs = schemaField.args + if (!schemaArgs) throw new Error(`Field has no args`) + const entries = Object.entries($) args = entries.length === 0 ? `` : `(${ - entries.map(([k, v]) => { - return `${k}: ${JSON.stringify(v)}` + entries.map(([argName, v]) => { + const schemaArg = schemaArgs.fields[argName] as Schema.Input.Any | undefined // eslint-disable-line + if (!schemaArg) throw new Error(`Arg ${argName} not found in schema field`) + if (schemaArg.kind === `Enum`) { + return `${argName}: ${String(v)}` + } else { + // todo if enum, do not quote, requires schema index + return `${argName}: ${JSON.stringify(v)}` + } }).join(`, `) })` } + return args +} +const pruneNonSelections = (ss: SS) => { + const entries = Object.entries(ss) + const selectEntries = entries.filter(_ => !_[0].startsWith(`$`)) + return Object.fromEntries(selectEntries) +} + +const indicatorOrSelectionSet = ( + context: Context, + schemaField: Schema.SomeField, + ss: null | Indicator | SS, +): string => { + if (ss === null) return `null` // todo test this case + if (isIndicator(ss)) return `` + + const entries = Object.entries(ss) + const selectEntries = entries.filter(_ => !_[0].startsWith(`$`)) + const directives = resolveDirectives(ss) + const args = resolveArgs(schemaField, ss) - if (Object.keys(rest).length === 0) { + if (selectEntries.length === 0) { return `${args} ${directives}` } + const selection = Object.fromEntries(selectEntries) as GraphQLObjectSelection + + // eslint-disable-next-line + // @ts-ignore ID error + const schemaNamedOutputType = Schema.Output.unwrapToNamed(schemaField.type) as Schema.Object$2 return `${args} ${directives} { - ${selectionSet(rest)} + ${selectionSet(context, readMaybeThunk(schemaNamedOutputType), selection)} }` } -export const selectionSet = (ss: GraphQLObjectSelection) => { - return Object.entries(ss).filter(([_, v]) => { - return isPositiveIndicator(v) - }).map(([field, ss]) => { - return `${resolveFragment(resolveAlias(field))} ${indicatorOrSelectionSet(ss)}` - }).join(`\n`) + `\n` +export const selectionSet = ( + context: Context, + schemaItem: Schema.Object$2 | Schema.Union | Schema.Interface, + ss: Indicator | SS, +): string => { + // todo optimize by doing single loop + const applicableSelections = Object.entries(ss).filter(([_, ss]) => isPositiveIndicator(ss)) as [ + string, + SS | Indicator, + ][] + switch (schemaItem.kind) { + case `Object`: { + const rootTypeName = (RootTypeName as Record)[schemaItem.fields.__typename.type.type] + ?? null + return applicableSelections.map(([fieldExpression, ss]) => { + const fieldName = parseFieldName(fieldExpression) + const schemaField = schemaItem.fields[fieldName.actual] + if (!schemaField) throw new Error(`Field ${fieldExpression} not found in schema object`) + // dprint-ignore + if (rootTypeName&&context.config.returnMode===`successData`&&context.schemaIndex.error.rootResultFields[rootTypeName][fieldName.actual]) { + (ss as Record)[`__typename`] = true + } + return `${resolveFragment(resolveAlias(fieldExpression))} ${indicatorOrSelectionSet(context, schemaField, ss)}` + }).join(`\n`) + `\n` + } + case `Interface`: { + return applicableSelections.map(([fieldExpression, ss]) => { + const fieldItem = parseFieldItem(fieldExpression) + switch (fieldItem._tag) { + case `FieldName`: { + if (fieldItem.actual === `__typename`) { + return `${renderFieldName(fieldItem)} ${resolveDirectives(ss)}` + } + const schemaField = schemaItem.fields[fieldItem.actual] + if (!schemaField) throw new Error(`Field ${fieldExpression} not found in schema object`) + // dprint-ignore + return `${resolveFragment(resolveAlias(fieldExpression))} ${ indicatorOrSelectionSet(context, schemaField, ss) }` + } + case `FieldOn`: { + const schemaObject = context.schemaIndex[`objects`][fieldItem.typeOrFragmentName] + if (!schemaObject) throw new Error(`Fragment ${fieldItem.typeOrFragmentName} not found in schema`) + return `${renderOn(fieldItem)} ${resolveDirectives(ss)} { ${selectionSet(context, schemaObject, ss)} }` + } + default: { + throw new Error(`Unknown field item tag`) + } + } + }).join(`\n`) + `\n` + } + case `Union`: { + return applicableSelections.map(([fieldExpression, ss]) => { + const fieldItem = parseFieldItem(fieldExpression) + switch (fieldItem._tag) { + case `FieldName`: { + if (fieldItem.actual === `__typename`) { + return `${renderFieldName(fieldItem)} ${resolveDirectives(ss)}` + } + // todo + throw new Error(`todo resolve common interface fields from unions`) + } + case `FieldOn`: { + const schemaObject = context.schemaIndex[`objects`][fieldItem.typeOrFragmentName] + if (!schemaObject) throw new Error(`Fragment ${fieldItem.typeOrFragmentName} not found in schema`) + // if (isIndicator(ss)) throw new Error(`Union field must have selection set`) + return `${renderOn(fieldItem)} ${resolveDirectives(ss)} { ${ + // @ts-expect-error fixme + selectionSet(context, schemaObject, pruneNonSelections(ss))} }` + } + default: { + throw new Error(`Unknown field item tag`) + } + } + }).join(`\n`) + `\n` + } + default: + throw new Error(`Unknown schema item kind`) + } +} + +type FieldItem = FieldOn | FieldName + +const parseFieldItem = (field: string): FieldItem => { + const on = parseOnExpression(field) + if (on) return on + return parseFieldName(field) +} + +interface FieldOn { + _tag: 'FieldOn' + typeOrFragmentName: string +} + +const parseOnExpression = (field: string): null | FieldOn => { + const match = field.match(fragmentPattern) + if (match?.groups) { + return { + _tag: `FieldOn`, + typeOrFragmentName: match.groups[`name`]!, + } + } + return null +} + +const renderOn = (on: FieldOn) => { + return `...on ${on.typeOrFragmentName}` } // todo use a given schema to ensure that field is actually a fragment and not just happened to be using pattern onX @@ -114,6 +265,36 @@ const resolveFragment = (field: string) => { return field } +interface FieldName { + _tag: 'FieldName' + actual: string + alias: string | null +} +// todo use a given schema to ensure that field is actually a fragment and not just happened to be using pattern onX +const parseFieldName = (field: string): FieldName => { + const match = field.match(aliasPattern) + if (match?.groups) { + return { + _tag: `FieldName`, + actual: match.groups[`actual`]!, + alias: match.groups[`alias`]!, + } + } + return { + _tag: `FieldName`, + actual: field, + alias: null, + } +} + +const renderFieldName = (fieldName: FieldName) => { + if (fieldName.alias) { + return `${fieldName.actual}: ${fieldName.alias}` + } else { + return fieldName.actual + } +} + // todo use a given schema to ensure that field is actually a fragment and not just happened to be using pattern onX const resolveAlias = (field: string) => { const match = field.match(aliasPattern) diff --git a/src/client/client.document.test.ts b/src/client/client.document.test.ts index 40abde398..1e47049d0 100644 --- a/src/client/client.document.test.ts +++ b/src/client/client.document.test.ts @@ -4,6 +4,8 @@ import { $Index } from '../../tests/_/schema/generated/SchemaRuntime.js' import { schema } from '../../tests/_/schema/schema.js' import { create } from './client.js' +// todo test with custom scalars + const client = create({ schema, schemaIndex: $Index }) describe(`document with two queries`, () => { diff --git a/src/client/client.input.test-d.ts b/src/client/client.input.test-d.ts index 8adbd0295..709b9872a 100644 --- a/src/client/client.input.test-d.ts +++ b/src/client/client.input.test-d.ts @@ -6,17 +6,17 @@ import { create } from './client.js' test(`works`, () => { create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `graphql` }) create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `data` }) - create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `dataAndAllErrors` }) + create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `dataAndErrors` }) // @ts-expect-error bad returnMode - create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `dataAndSchemaErrors` }) + create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `successData` }) create({ schemaIndex: $Index, schema, name: `default`, returnMode: `graphql` }) create({ schemaIndex: $Index, schema, name: `default`, returnMode: `data` }) - create({ schemaIndex: $Index, schema, name: `default`, returnMode: `dataAndAllErrors` }) - create({ schemaIndex: $Index, schema, name: `default`, returnMode: `dataAndSchemaErrors` }) + create({ schemaIndex: $Index, schema, name: `default`, returnMode: `dataAndErrors` }) + create({ schemaIndex: $Index, schema, name: `default`, returnMode: `successData` }) create({ schemaIndex: $Index, schema, returnMode: `graphql` }) create({ schemaIndex: $Index, schema, returnMode: `data` }) - create({ schemaIndex: $Index, schema, returnMode: `dataAndAllErrors` }) - create({ schemaIndex: $Index, schema, returnMode: `dataAndSchemaErrors` }) + create({ schemaIndex: $Index, schema, returnMode: `dataAndErrors` }) + create({ schemaIndex: $Index, schema, returnMode: `successData` }) }) diff --git a/src/client/client.returnMode.test-d.ts b/src/client/client.returnMode.test-d.ts index 8c97e1340..9c29ef6d0 100644 --- a/src/client/client.returnMode.test-d.ts +++ b/src/client/client.returnMode.test-d.ts @@ -11,15 +11,18 @@ import { create } from './client.js' // dprint-ignore describe('default is data', () => { const client = create({ schema, schemaIndex }) - test(`document`, async () => { + test(`document.run`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf<{ id: string | null }>() }) - test('query field method', async () => { + test('query.', async () => { await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query'>() }) - test('query $batch', async () => { + test('query.$batch', async () => { await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null }>() }) + test('query.', async () => { + await expectTypeOf(client.query.result({$:{case:'Object1'},__typename:true})).resolves.toEqualTypeOf<{__typename: "Object1"} | {__typename: "ErrorOne"} | {__typename: "ErrorTwo"} | null>() + }) test(`raw`, async () => { expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() }) @@ -28,18 +31,20 @@ describe('default is data', () => { // dprint-ignore describe('data', () => { const client = create({ schema, schemaIndex, returnMode: 'data' }) - test(`document`, async () => { + test(`document.run`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf<{ id: string | null }>() }) - test('query field method', async () => { + test('query.', async () => { await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query'>() }) - test('query $batch', async () => { + test('query.$batch', async () => { await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null }>() }) - test('result',async () => { - const x = await client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}}) - await expectTypeOf(client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}})).resolves.toEqualTypeOf() + test('query.',async () => { + await expectTypeOf(client.query.result({$:{case:'Object1'},__typename:true})).resolves.toEqualTypeOf<{__typename: "Object1"} | {__typename: "ErrorOne"} | {__typename: "ErrorTwo"} | null>() + }) + test('query. orThrow',async () => { + await expectTypeOf(client.query.resultOrThrow({$:{case:'Object1'},__typename:true})).resolves.toEqualTypeOf<{__typename: "Object1"} | null>() }) test(`raw`, async () => { expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() @@ -47,23 +52,64 @@ describe('data', () => { }) // dprint-ignore -describe('dataAndAllErrors', () => { - const client = create({ schema, schemaIndex, returnMode: 'dataAndAllErrors' }) +describe('successData', () => { + const client = create({ schema, schemaIndex, returnMode: 'successData' }) + test(`document.run`, async () => { + expectTypeOf(client.document({ x: { query: { id: true } } }).run()).resolves.toEqualTypeOf<{ id: string | null }>() + }) + test(`document.runOrThrow`, async () => { + expectTypeOf(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqualTypeOf<{ id: string | null }>() + }) + test('query.', async () => { + await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query'>() + }) + test('query.$batch', async () => { + await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null }>() + }) + describe('result field', () => { + test('document.run',async () => { + await expectTypeOf(client.document({x:{query:{result:{$:{case:'Object1'},__typename:true}}}}).run()).resolves.toEqualTypeOf<{result:{__typename: "Object1"} | null}>() + }) + test('query.',async () => { + await expectTypeOf(client.query.result({$:{case:'Object1'},__typename:true})).resolves.toEqualTypeOf<{__typename: "Object1"} | null>() + }) + test('query.$batch', async () => { + await expectTypeOf(client.query.$batch({ result:{$:{case:'Object1'},__typename:true} })).resolves.toEqualTypeOf<{result:{__typename: "Object1"} | null}>() + }) + describe('without explicit __typename', () => { + test('document',async () => { + await expectTypeOf(client.document({x:{query:{result:{$:{case:'Object1'}}}}}).run()).resolves.toEqualTypeOf<{result:{__typename: "Object1"} | null}>() + }) + test('query.',async () => { + await expectTypeOf(client.query.result({$:{case:'Object1'}})).resolves.toEqualTypeOf<{__typename: "Object1"} | null>() + }) + test('query.$batch', async () => { + await expectTypeOf(client.query.$batch({ result:{$:{case:'Object1'}} })).resolves.toEqualTypeOf<{result:{__typename: "Object1"} | null}>() + }) + }) + }) + test(`raw`, async () => { + expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() + }) +}) + +// dprint-ignore +describe('dataAndErrors', () => { + const client = create({ schema, schemaIndex, returnMode: 'dataAndErrors' }) test(`document`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf<{ id: string | null } | GraphQLExecutionResultError>() }) test(`document runOrThrow`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqualTypeOf<{ id: string | null }>() }) - test('query field method', async () => { + test('query.', async () => { await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query' | GraphQLExecutionResultError>() }) - test('query $batch', async () => { + test('query.$batch', async () => { await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null } | GraphQLExecutionResultError>() }) - test('result',async () => { - const x = await client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}}) - await expectTypeOf(client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}})).resolves.toEqualTypeOf() + test('query.',async () => { + await expectTypeOf(client.query.result({$:{case:'Object1'},__typename:true})).resolves.toEqualTypeOf<{__typename: "Object1"} | {__typename: "ErrorOne"} | {__typename: "ErrorTwo"} | null | GraphQLExecutionResultError>() }) test(`raw`, async () => { expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() @@ -73,25 +119,37 @@ describe('dataAndAllErrors', () => { // dprint-ignore describe('graphql', () => { const client = create({ schema, schemaIndex, returnMode: 'graphql' }) - test(`document`, async () => { + test(`document.run`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf>>() }) - test(`document runOrThrow`, async () => { + test(`document.runOrThrow`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqualTypeOf>>() }) - test('query field method', async () => { + test('query.', async () => { await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf>() }) - test('query field methodOrThrow', async () => { + test('query.OrThrow', async () => { await expectTypeOf(client.query.__typenameOrThrow()).resolves.toEqualTypeOf>() }) - test('query $batch', async () => { + test('query.$batch', async () => { await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf>() }) - test('query $batchOrThrow', async () => { + test('query.$batchOrThrow', async () => { await expectTypeOf(client.query.$batchOrThrow({ __typename: true, id: true })).resolves.toEqualTypeOf>() }) + test('query.',async () => { + await expectTypeOf(client.query.result({$:{case:'Object1'},__typename:true})).resolves.toEqualTypeOf>() + }) test(`raw`, async () => { expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() }) }) + +type z = { + __typename: 'ErrorOne' +} | { + __typename: 'ErrorTwo' +} + +type y = Exclude<{ id: string | null } | {} | {} | null, { id: 1 }> +type y2 = Exclude<{ id: string | null } | {} | {} | null, z> diff --git a/src/client/client.returnMode.test.ts b/src/client/client.returnMode.test.ts index 34bc0d146..2fcaa972b 100644 --- a/src/client/client.returnMode.test.ts +++ b/src/client/client.returnMode.test.ts @@ -1,10 +1,8 @@ /* eslint-disable */ -import { GraphQLError } from 'graphql' import { describe, expect, test } from 'vitest' import { db } from '../../tests/_/db.js' import { $Index as schemaIndex } from '../../tests/_/schema/generated/SchemaRuntime.js' import { schema } from '../../tests/_/schema/schema.js' -import { Errors } from '../lib/errors/__.js' import { __typename } from '../Schema/_.js' import { create } from './client.js' @@ -14,110 +12,164 @@ describe('default (data)', () => { test(`document`, async () => { await expect(client.document({ main: { query: { id: true } } }).run()).resolves.toEqual({ id: db.id }) }) - test(`document runOrThrow`, async () => { + test(`document.runOrThrow`, async () => { await expect(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqual({ id: db.id }) }) - test(`document runOrThrow error`, async () => { + test(`document.runOrThrow error`, async () => { await expect(client.document({ main: { query: { error: true } } }).runOrThrow()).rejects.toEqual(db.error) }) test('raw', async () => { await expect(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqual({ data: { id: db.id } }) }) - test('query field method', async () => { + test('query.', async () => { await expect(client.query.__typename()).resolves.toEqual('Query') }) - test('query field method error', async () => { + test('query. error', async () => { await expect(client.query.error()).rejects.toMatchObject(db.error) }) - test('query field method error orThrow', async () => { + test('query. error orThrow', async () => { await expect(client.query.errorOrThrow()).rejects.toMatchObject(db.error) }) - test('query $batch', async () => { + test('query.$batch', async () => { await expect(client.query.$batch({ __typename: true, id: true })).resolves.toEqual({ __typename: 'Query', id: db.id }) }) - test('query $batchOrThrow error', async () => { + test('query.$batchOrThrow error', async () => { await expect(client.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.error) }) - test('mutation field method', async () => { + test('mutation.', async () => { await expect(client.mutation.__typename()).resolves.toEqual('Mutation') }) - test('mutation $batch', async () => { + test('mutation.$batch', async () => { await expect(client.mutation.$batch({ __typename: true, id: true })).resolves.toEqual({ __typename: 'Mutation', id: db.id }) }) }) // dprint-ignore -describe('dataAndAllErrors', () => { - const client = create({ schema, schemaIndex, returnMode: 'dataAndAllErrors' }) - test(`document`, async () => { +describe('dataAndErrors', () => { + const client = create({ schema, schemaIndex, returnMode: 'dataAndErrors' }) + test(`document.run`, async () => { await expect(client.document({ main: { query: { id: true } } }).run()).resolves.toEqual({ id: db.id }) }) - test(`document runOrThrow`, async () => { + test(`document.runOrThrow`, async () => { await expect(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqual({ id: db.id }) }) - test(`document runOrThrow error`, async () => { + test(`document.runOrThrow error`, async () => { await expect(client.document({ main: { query: { error: true } } }).runOrThrow()).rejects.toEqual(db.error) }) test('raw', async () => { await expect(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqual({ data: { id: db.id } }) }) - test('query field method', async () => { + test('query.', async () => { await expect(client.query.__typename()).resolves.toEqual('Query') }) - test('query field method error', async () => { + test('query. error', async () => { await expect(client.query.error()).resolves.toMatchObject(db.error) }) - test('query field method error orThrow', async () => { + test('query. error orThrow', async () => { await expect(client.query.errorOrThrow()).rejects.toMatchObject(db.error) }) - test('query $batch', async () => { + test('query.$batch', async () => { await expect(client.query.$batch({ __typename: true, id: true })).resolves.toEqual({ __typename: 'Query', id: db.id }) }) - test('query $batchOrThrow error', async () => { + test('query.$batchOrThrow error', async () => { await expect(client.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.error) }) - test('mutation field method', async () => { + test('mutation.', async () => { await expect(client.mutation.__typename()).resolves.toEqual('Mutation') }) - test('mutation $batch', async () => { + test('mutation.$batch', async () => { await expect(client.mutation.$batch({ __typename: true, id: true })).resolves.toEqual({ __typename: 'Mutation', id: db.id }) }) }) +// dprint-ignore +describe('successData', () => { + const client = create({ schema, schemaIndex, returnMode: 'successData' }) + test(`document.run`, async () => { + expect(client.document({ x: { query: { id: true } } }).run()).resolves.toEqual({ id: db.id }) + }) + test(`document.runOrThrow`, async () => { + expect(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqual({ id: db.id }) + }) + test('query.', async () => { + await expect(client.query.__typename()).resolves.toEqual('Query') + }) + test('query.$batch', async () => { + await expect(client.query.$batch({ __typename: true, id: true })).resolves.toEqual({ __typename: 'Query', id: db.id }) + }) + describe('result field', () => { + test('document.run',async () => { + await expect(client.document({x:{query:{result:{$:{case:'Object1'},__typename:true}}}}).run()).resolves.toEqual({result:{__typename: "Object1"}}) + }) + test('query.', async () => { + await expect(client.query.result({$:{case:'Object1'},__typename:true})).resolves.toEqual({ __typename: "Object1" }) + }) + test('query.$batch', async () => { + await expect(client.query.$batch({ result:{$:{case:'Object1'},__typename:true} })).resolves.toEqual({result:{__typename: "Object1"}}) + }) + describe('without explicit __typename', () => { + test('document', async () => { + await expect(client.document({x:{query:{result:{$:{case:'Object1'}}}}}).run()).resolves.toEqual({result:{__typename: "Object1"}}) + }) + test('query.', async () => { + await expect(client.query.result({$:{case:'Object1'}})).resolves.toEqual({__typename: "Object1"}) + }) + test('query.$batch', async () => { + await expect(client.query.$batch({ result:{$:{case:'Object1'}} })).resolves.toEqual({result:{__typename: "Object1"}}) + }) + }) + describe('throws', async () => { + test('document', async () => { + await expect(client.document({x:{query:{result:{$:{case:'ErrorOne'}}}}}).run()).rejects.toEqual(db.ErrorOneError) + }) + test('query.', async () => { + await expect(client.query.result({$:{case:'ErrorOne'}})).rejects.toEqual(db.ErrorOneError) + }) + test('query.$batch', async () => { + await expect(client.query.$batch({ result:{$:{case:'ErrorOne'}} })).rejects.toEqual(db.ErrorOneError) + }) + // todo result twice, using aliases, check that aggregate error is thrown + }) + }) + test(`raw`, async () => { + expect(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqual({data:{id:db.id}}) + }) +}) + // dprint-ignore describe('graphql', () => { const client = create({ schema, schemaIndex, returnMode: 'graphql' }) - test(`document`, async () => { + test(`document.run`, async () => { await expect(client.document({ main: { query: { id: true } } }).run()).resolves.toEqual({ data: { id: db.id } }) // dprint-ignore }) - test(`document runOrThrow`, async () => { + test(`document.runOrThrow`, async () => { await expect(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqual({data:{ id: db.id }}) }) - test(`document runOrThrow error`, async () => { + test(`document.runOrThrow error`, async () => { await expect(client.document({ main: { query: { error: true } } }).runOrThrow()).rejects.toEqual(db.error) }) test('raw', async () => { await expect(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqual({ data: { id: db.id } }) }) - test('query field method', async () => { + test('query.', async () => { await expect(client.query.__typename()).resolves.toEqual({ data: { __typename: 'Query' } }) }) - test('query field method error', async () => { + test('query. error', async () => { await expect(client.query.error()).resolves.toMatchObject({ errors:db.error['errors'] }) }) - test('query field method error orThrow', async () => { + test('query. orThrow error', async () => { await expect(client.query.errorOrThrow()).rejects.toMatchObject(db.error) }) - test('query $batch', async () => { + test('query.$batch', async () => { await expect(client.query.$batch({ __typename: true, id: true })).resolves.toEqual({ data: { __typename: 'Query', id: db.id } }) }) - test('query $batchOrThrow error', async () => { + test('query.$batchOrThrow error', async () => { await expect(client.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.error) }) - test('mutation field method', async () => { + test('mutation.', async () => { await expect(client.mutation.__typename()).resolves.toEqual({ data: { __typename: 'Mutation' } }) }) - test('mutation $batch', async () => { + test('mutation.$batch', async () => { await expect(client.mutation.$batch({ __typename: true, id: true })).resolves.toEqual({ data: { __typename: 'Mutation', id: db.id } }) }) }) diff --git a/src/client/client.ts b/src/client/client.ts index c02f2e85a..5c3801815 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -5,16 +5,23 @@ import request from '../entrypoints/main.js' import type { GlobalRegistry } from '../globalRegistry.js' import { Errors } from '../lib/errors/__.js' import type { RootTypeName, Variables } from '../lib/graphql.js' +import { isPlainObject } from '../lib/prelude.js' import type { Object$2 } from '../Schema/__.js' import { Schema } from '../Schema/__.js' import { readMaybeThunk } from '../Schema/core/helpers.js' -import type { ApplyInputDefaults, Config, ReturnModeTypeBase, ReturnModeTypeDataAndSchemaErrors } from './Config.js' +import type { + ApplyInputDefaults, + Config, + ReturnModeType, + ReturnModeTypeBase, + ReturnModeTypeSuccessData, +} from './Config.js' import * as CustomScalars from './customScalars.js' import type { DocumentFn } from './document.js' -import { toDocumentExpression } from './document.js' +import { rootTypeNameToOperationName, toDocumentString } from './document.js' import type { GetRootTypeMethods } from './RootTypeMethods.js' import { SelectionSet } from './SelectionSet/__.js' -import type { DocumentObject, GraphQLObjectSelection } from './SelectionSet/toGraphQLDocumentString.js' +import type { Context, DocumentObject, GraphQLObjectSelection } from './SelectionSet/toGraphQLDocumentString.js' // dprint-ignore export type Client<$Index extends Schema.Index, $Config extends Config> = @@ -49,7 +56,7 @@ type InputForSchema<$Name extends GlobalRegistry.SchemaNames> = $Name extends an schemaIndex: Schema.Index returnMode?: | ReturnModeTypeBase - | (GlobalRegistry.HasSchemaErrors<$Name> extends true ? ReturnModeTypeDataAndSchemaErrors : never) + | (GlobalRegistry.HasSchemaErrors<$Name> extends true ? ReturnModeTypeSuccessData : never) hooks?: { documentEncode: ( input: HookInputDocumentEncode, @@ -68,7 +75,19 @@ export const create = <$Input extends Input>( ApplyInputDefaults<{ returnMode: $Input['returnMode'] }> > => { const parentInput = input - const returnMode = input.returnMode ?? `data` + /** + * @remarks Without generation the type of returnMode can be `ReturnModeTypeBase` which leads + * TS to think some errors below are invalid checks because of a non-present member. + * However our implementation here needs to be generic and support all return modes + * so we force cast it as such. + */ + const returnMode = input.returnMode ?? `data` as ReturnModeType + const encodeContext: Context = { + schemaIndex: input.schemaIndex, + config: { + returnMode, + }, + } const runHookable = <$Name extends keyof ExcludeUndefined>( name: $Name, @@ -78,7 +97,7 @@ export const create = <$Input extends Input>( return parentInput.hooks?.[name](input, fn) ?? fn(input) } - const executeDocumentExpression = async ( + const executeDocumentString = async ( { document, variables, operationName }: { document: string | DocumentNode variables?: Variables @@ -120,31 +139,33 @@ export const create = <$Input extends Input>( return result } - const executeRootObject = - (rootTypeName: RootTypeName) => async (documentObject: GraphQLObjectSelection): Promise => { + const executeRootType = + (rootTypeName: RootTypeName) => async (selection: GraphQLObjectSelection): Promise => { const rootIndex = input.schemaIndex.Root[rootTypeName] if (!rootIndex) throw new Error(`Root type not found: ${rootTypeName}`) - const documentObjectEncoded = runHookable( + // todo one encoding pass + const selectionEncoded = runHookable( `documentEncode`, - { rootIndex, documentObject }, + // todo rename to rootObject + { rootIndex, documentObject: selection }, ({ rootIndex, documentObject }) => CustomScalars.encode({ index: rootIndex, documentObject }), ) - const documentString = SelectionSet.selectionSet(documentObjectEncoded) + const documentString = SelectionSet.Print.rootTypeSelectionSet( + encodeContext, + rootIndex, + // @ts-expect-error fixme + selectionEncoded[rootTypeNameToOperationName[rootTypeName]], + ) + // console.log(documentString) // todo variables - const result = await executeDocumentExpression({ document: documentString }) + const result = await executeDocumentString({ document: documentString }) // if (result.errors && (result.errors.length > 0)) throw new AggregateError(result.errors) const dataDecoded = CustomScalars.decode(rootIndex, result.data) return { ...result, data: dataDecoded } } - const rootTypeNameToOperationName = { - Query: `query`, - Mutation: `mutation`, - Subscription: `subscription`, - } as const - - const executeRootTypeFieldSelection = (rootTypeName: RootTypeName, key: string) => { + const executeRootTypeField = (rootTypeName: RootTypeName, key: string) => { return async (argsOrSelectionSet?: object) => { const type = readMaybeThunk( // eslint-disable-next-line @@ -165,14 +186,17 @@ export const create = <$Input extends Input>( const result = await rootObjectExecutors[rootTypeName](documentObject) const resultHandled = handleReturn(result) if (resultHandled instanceof Error) return resultHandled - // @ts-expect-error make this type safe? - return returnMode === `data` || returnMode === `dataAndAllErrors` ? resultHandled[key] : resultHandled + return returnMode === `data` || returnMode === `dataAndErrors` || returnMode === `successData` + // @ts-expect-error make this type safe? + ? resultHandled[key] + : resultHandled } } const handleReturn = (result: ExecutionResult) => { switch (returnMode) { - case `dataAndAllErrors`: + case `dataAndErrors`: + case `successData`: case `data`: { if (result.errors && result.errors.length > 0) { const error = new Errors.ContextualAggregateError( @@ -180,9 +204,35 @@ export const create = <$Input extends Input>( {}, result.errors, ) - if (returnMode === `data`) throw error + if (returnMode === `data` || returnMode === `successData`) throw error return error } + if (returnMode === `successData`) { + if (!isPlainObject(result.data)) throw new Error(`Expected data to be an object.`) + const schemaErrors = Object.entries(result.data).map(([rootFieldName, rootFieldValue]) => { + // todo do not hardcode root type + const isResultField = Boolean(input.schemaIndex.error.rootResultFields.Query[rootFieldName]) + if (!isResultField) return null + if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`) + const __typename = rootFieldValue[`__typename`] + if (typeof __typename !== `string`) throw new Error(`Expected __typename to be selected and a string.`) + const isErrorObject = Boolean( + input.schemaIndex.error.objectsTypename[__typename], + ) + if (!isErrorObject) return null + // todo extract message + return new Error(`Failure on field ${rootFieldName}: ${__typename}`) + }).filter((_): _ is Error => _ !== null) + if (schemaErrors.length === 1) throw schemaErrors[0]! + if (schemaErrors.length > 0) { + const error = new Errors.ContextualAggregateError( + `One or more schema errors in the execution result.`, + {}, + schemaErrors, + ) + throw error + } + } return result.data } default: { @@ -192,9 +242,9 @@ export const create = <$Input extends Input>( } const rootObjectExecutors = { - Mutation: executeRootObject(`Mutation`), - Query: executeRootObject(`Query`), - Subscription: executeRootObject(`Subscription`), + Mutation: executeRootType(`Mutation`), + Query: executeRootType(`Query`), + Subscription: executeRootType(`Subscription`), } const createRootTypeMethods = (rootTypeName: RootTypeName) => @@ -227,7 +277,7 @@ export const create = <$Input extends Input>( } else { const fieldName = isOrThrow ? key.slice(0, -7) : key return async (argsOrSelectionSet?: object) => { - const result = await executeRootTypeFieldSelection(rootTypeName, fieldName)(argsOrSelectionSet) // eslint-disable-line + const result = await executeRootTypeField(rootTypeName, fieldName)(argsOrSelectionSet) // eslint-disable-line if (isOrThrow && result instanceof Error) throw result // todo consolidate // eslint-disable-next-line @@ -248,14 +298,29 @@ export const create = <$Input extends Input>( // @ts-expect-error ignoreme const client: Client = { raw: async (document: string | DocumentNode, variables?: Variables, operationName?: string) => { - return await executeDocumentExpression({ document, variables, operationName }) + return await executeDocumentString({ document, variables, operationName }) }, document: (documentObject: DocumentObject) => { const run = async (operationName: string) => { + // 1. if returnMode is successData OR using orThrow + // 2. for each root type key + // 3. filter to only result fields + // 4. inject __typename selection + // if (returnMode === 'successData') { + // Object.values(documentObject).forEach((rootTypeSelection) => { + // Object.entries(rootTypeSelection).forEach(([fieldExpression, fieldValue]) => { + // if (fieldExpression === 'result') { + // // @ts-expect-error fixme + // fieldValue.__typename = true + // } + // }) + // }) + // } // todo this does not support custom scalars - const documentExpression = toDocumentExpression(documentObject) - const result = await executeDocumentExpression({ - document: documentExpression, + + const documentString = toDocumentString(encodeContext, documentObject) + const result = await executeDocumentString({ + document: documentString, operationName, // todo variables }) diff --git a/src/client/customScalars.ts b/src/client/customScalars.ts index 4d0957057..1856e8e80 100644 --- a/src/client/customScalars.ts +++ b/src/client/customScalars.ts @@ -19,7 +19,7 @@ namespace SSValue { export const encode = ( input: { index: Schema.Object$2 - documentObject: SelectionSet.GraphQLObjectSelection + documentObject: SelectionSet.Print.GraphQLObjectSelection }, ): GraphQLObjectSelection => { return Object.fromEntries( @@ -55,22 +55,29 @@ const encodeCustomScalarsArgs = (indexArgs: Args, valueArgs: SSValue.Args2) const encodeCustomScalarsArgValue = (indexArgMaybeThunk: Schema.Input.Any, argValue: null | SSValue.Arg): any => { if (argValue === null) return null // todo could check if index agrees is nullable. const indexArg = readMaybeThunk(indexArgMaybeThunk) - if (indexArg.kind === `nullable`) { - return encodeCustomScalarsArgValue(indexArg.type, argValue) - } - if (indexArg.kind === `list`) { - if (!Array.isArray(argValue)) throw new Error(`Expected array. Got: ${String(argValue)}`) - return argValue.map(_ => encodeCustomScalarsArgValue(indexArg.type, _)) - } - if (indexArg.kind === `InputObject`) { - // dprint-ignore - if (typeof argValue !== `object` || Array.isArray(argValue)) throw new Error(`Expected object. Got: ${String(argValue)}`) - const fields = Object.fromEntries(Object.entries(indexArg.fields).map(([k, v]) => [k, v.type])) // eslint-disable-line - return encodeCustomScalarsArgs({ fields }, argValue) + switch (indexArg.kind) { + case `nullable`: + return encodeCustomScalarsArgValue(indexArg.type, argValue) + case `list`: { + if (!Array.isArray(argValue)) throw new Error(`Expected array. Got: ${String(argValue)}`) + return argValue.map(_ => encodeCustomScalarsArgValue(indexArg.type, _)) + } + case `InputObject`: { + // dprint-ignore + if (typeof argValue !== `object` || Array.isArray(argValue)) throw new Error(`Expected object. Got: ${String(argValue)}`) + const fields = Object.fromEntries(Object.entries(indexArg.fields).map(([k, v]) => [k, v.type])) // eslint-disable-line + return encodeCustomScalarsArgs({ fields }, argValue) + } + case `Enum`: { + return argValue + } + case `Scalar`: { + // @ts-expect-error fixme + return indexArg.codec.encode(argValue) + } + default: + throw new Error(`Unsupported arg kind: ${JSON.stringify(indexArg)}`) } - // @ts-expect-error fixme - if (indexArg.kind === `Scalar`) return indexArg.codec.encode(argValue) - throw new Error(`Unsupported arg kind: ${String(indexArg)}`) } export const decode = <$Data extends ExecutionResult['data']>(index: Schema.Object$2, data: $Data): $Data => { diff --git a/src/client/document.ts b/src/client/document.ts index d73df0c4d..c758bb843 100644 --- a/src/client/document.ts +++ b/src/client/document.ts @@ -2,10 +2,10 @@ import type { MergeExclusive, NonEmptyObject } from 'type-fest' import type { IsMultipleKeys } from '../lib/prelude.js' import type { TSError } from '../lib/TSError.js' import type { Schema } from '../Schema/__.js' -import type { Config, OrThrowifyConfig, ReturnMode } from './Config.js' +import type { AugmentRootTypeSelectionWithTypename, Config, OrThrowifyConfig, ReturnModeRootType } from './Config.js' import type { ResultSet } from './ResultSet/__.js' import { SelectionSet } from './SelectionSet/__.js' -import type { DocumentObject } from './SelectionSet/toGraphQLDocumentString.js' +import type { Context, DocumentObject } from './SelectionSet/toGraphQLDocumentString.js' // dprint-ignore export type DocumentFn<$Config extends Config, $Index extends Schema.Index> = @@ -15,24 +15,35 @@ export type DocumentFn<$Config extends Config, $Index extends Schema.Index> = $Name extends keyof $Document & string, $Params extends (IsMultipleKeys<$Document> extends true ? [name: $Name] : ([] | [name: $Name | undefined])), >(...params: $Params) => Promise< - ReturnMode<$Config, ResultSet.Root, $Index, 'Query'>> + ReturnModeRootType<$Config, $Index, ResultSet.Root, $Index, GetRootType<$Document[$Name]>>> > runOrThrow: < $Name extends keyof $Document & string, $Params extends (IsMultipleKeys<$Document> extends true ? [name: $Name] : ([] | [name: $Name | undefined])), >(...params: $Params) => Promise< - ReturnMode, ResultSet.Root, $Index, 'Query'>> + ReturnModeRootType, $Index, ResultSet.Root, $Index, $Document[$Name]>, $Index, GetRootType<$Document[$Name]>>> > } -export const toDocumentExpression = ( +export const toDocumentString = ( + context: Context, document: DocumentObject, ) => { - return Object.entries(document).map(([operationName, operationInput]) => { - const operationType = `query` in operationInput ? `query` : `mutation` - const operation = `query` in operationInput ? operationInput[`query`] : operationInput[`mutation`] - const documentString = SelectionSet.toGraphQLDocumentSelectionSet(operation) - return `${operationType} ${operationName} ${documentString}` + return Object.entries(document).map(([operationName, operationDocument]) => { + const operationType = `query` in operationDocument ? `query` : `mutation` + const rootType = operationTypeToRootType[operationType] + const rootTypeDocument = (operationDocument as any)[operationType] as SelectionSet.Print.GraphQLObjectSelection // eslint-disable-line + + const schemaRootType = context.schemaIndex[`Root`][rootType] + if (!schemaRootType) throw new Error(`Schema has no ${rootType} root type`) + + const documentString = SelectionSet.Print.rootTypeSelectionSet( + context, + schemaRootType, + rootTypeDocument, + operationName, + ) + return documentString }).join(`\n\n`) } @@ -66,7 +77,29 @@ export type ValidateDocumentOperationNames<$Document> = : TSError<'ValidateDocumentOperationNames', `One or more Invalid operation name in document: ${keyof { [K in keyof $Document & string as Schema.Named.NameParse extends never ? K : never]: K }}`> // dprint-ignore -type GetOperation = - T extends {query:infer U} ? U : - T extends {mutation:infer U} ? U : +type GetRootTypeSelection< + $Config extends Config, + $Index extends Schema.Index, + $Selection extends object +> = + $Selection extends { query: infer U extends object } ? AugmentRootTypeSelectionWithTypename<$Config, $Index, 'Query', U> : + $Selection extends { mutation: infer U extends object } ? AugmentRootTypeSelectionWithTypename<$Config, $Index, 'Mutation', U> : never + +// dprint-ignore +type GetRootType<$Selection extends object> = + $Selection extends {query:any} ? 'Query' : + $Selection extends {mutation:any} ? 'Mutation' : + never + +export const operationTypeToRootType = { + query: `Query`, + mutation: `Mutation`, + subscription: `Subscription`, +} as const + +export const rootTypeNameToOperationName = { + Query: `query`, + Mutation: `mutation`, + Subscription: `subscription`, +} as const diff --git a/src/generator/__snapshots__/files.test.ts.snap b/src/generator/__snapshots__/files.test.ts.snap index 204520e6c..9e520e156 100644 --- a/src/generator/__snapshots__/files.test.ts.snap +++ b/src/generator/__snapshots__/files.test.ts.snap @@ -134,6 +134,7 @@ exports[`schema2 3`] = ` import type * as Schema from './SchemaBuildtime.js' export interface Index { + name: 'default' Root: { Query: Schema.Root.Query Mutation: Schema.Root.Mutation @@ -170,6 +171,17 @@ export interface Index { ErrorOne: Schema.Object.ErrorOne ErrorTwo: Schema.Object.ErrorTwo } + objectsTypename: { + ErrorOne: { __typename: 'ErrorOne' } + ErrorTwo: { __typename: 'ErrorTwo' } + } + rootResultFields: { + Query: { + result: 'result' + } + Mutation: {} + Subscription: {} + } } } " @@ -717,6 +729,7 @@ export const Query = $.Object$(\`Query\`, { }) export const $Index = { + name: 'default' as const, Root: { Query, Mutation, @@ -753,6 +766,17 @@ export const $Index = { ErrorOne, ErrorTwo, }, + objectsTypename: { + ErrorOne: { __typename: 'ErrorOne' }, + ErrorTwo: { __typename: 'ErrorTwo' }, + }, + rootResultFields: { + Query: { + result: 'result' as const, + }, + Mutation: {}, + Subscription: {}, + }, }, } " diff --git a/src/generator/code/Index.ts b/src/generator/code/Index.ts index 524dd3af9..18bd18fe2 100644 --- a/src/generator/code/Index.ts +++ b/src/generator/code/Index.ts @@ -1,5 +1,6 @@ +import { isUnionType } from 'graphql' import { Code } from '../../lib/Code.js' -import { hasMutation, hasQuery, hasSubscription } from '../../lib/graphql.js' +import { hasMutation, hasQuery, hasSubscription, unwrapToNamed } from '../../lib/graphql.js' import type { Config } from './generateCode.js' import { moduleNameSchemaBuildtime } from './SchemaBuildtime.js' @@ -15,6 +16,7 @@ export const generateIndex = (config: Config) => { Code.interface$( `Index`, Code.objectFrom({ + name: Code.quote(config.name), Root: { type: Code.objectFrom({ Query: hasQuery(config.typeMapByKind) ? `${namespace}.Root.Query` : null, @@ -37,6 +39,24 @@ export const generateIndex = (config: Config) => { objects: Code.objectFromEntries( config.error.objects.map(_ => [_.name, `${namespace}.Object.${_.name}`]), ), + objectsTypename: Code.objectFromEntries( + config.error.objects.map(_ => [_.name, `{ __typename: "${_.name}" }`]), + ), + rootResultFields: `{ + ${ + Object.entries(config.rootTypes).map(([rootTypeName, rootType]) => { + if (!rootType) return `${rootTypeName}: {}` + + const resultFields = Object.values(rootType.getFields()).filter((field) => { + const type = unwrapToNamed(field.type) + return isUnionType(type) + && type.getTypes().some(_ => config.error.objects.some(__ => __.name === _.name)) + }).map((field) => field.name) + + return `${rootType.name}: {\n${resultFields.map(_ => `${_}: "${_}"`).join(`,\n`)} }` + }).join(`\n`) + } + }`, }), }), ), diff --git a/src/generator/code/SchemaRuntime.ts b/src/generator/code/SchemaRuntime.ts index 090490ab4..b35aa71d3 100644 --- a/src/generator/code/SchemaRuntime.ts +++ b/src/generator/code/SchemaRuntime.ts @@ -61,6 +61,7 @@ const index = (config: Config) => { // todo input objects for decode/encode input object fields return ` export const $Index = { + name: "${config.name}" as const, Root: { Query ${hasQuery(config.typeMapByKind) ? `` : `:null`} , Mutation ${hasMutation(config.typeMapByKind) ? `` : `:null`}, @@ -78,6 +79,24 @@ const index = (config: Config) => { error: { objects: { ${config.error.objects.map(type => type.name).join(`,\n`)} + }, + objectsTypename: { + ${config.error.objects.map(_ => `${_.name}: { __typename: "${_.name}" }`).join(`,\n`)} + }, + rootResultFields: { + ${ + Object.entries(config.rootTypes).map(([rootTypeName, rootType]) => { + if (!rootType) return `${rootTypeName}: {}` + + const resultFields = Object.values(rootType.getFields()).filter((field) => { + const type = unwrapToNamed(field.type) + return isUnionType(type) + && type.getTypes().some(_ => config.error.objects.some(__ => __.name === _.name)) + }).map((field) => field.name) + + return `${rootType.name}: {\n${resultFields.map(_ => `${_}: "${_}" as const`).join(`,\n`)} }` + }).join(`,\n`) + } } } } diff --git a/src/generator/code/generateCode.ts b/src/generator/code/generateCode.ts index efaae8fb6..a84f7d370 100644 --- a/src/generator/code/generateCode.ts +++ b/src/generator/code/generateCode.ts @@ -45,6 +45,11 @@ export interface Config { name: string schema: GraphQLSchema typeMapByKind: TypeMapByKind + rootTypes: { + Query: GraphQLObjectType | null + Mutation: GraphQLObjectType | null + Subscription: GraphQLObjectType | null + } error: { objects: GraphQLObjectType[] enabled: boolean @@ -87,6 +92,11 @@ export const resolveOptions = (input: Input): Config => { schema: input.libraryPaths?.schema ?? `graphql-request/alpha/schema`, }, typeMapByKind, + rootTypes: { + Query: typeMapByKind.GraphQLRootType.find(_ => _.name === `Query`) ?? null, + Mutation: typeMapByKind.GraphQLRootType.find(_ => _.name === `Mutation`) ?? null, + Subscription: typeMapByKind.GraphQLRootType.find(_ => _.name === `Subscription`) ?? null, + }, options: { errorTypeNamePattern, customScalars: input.options?.customScalars ?? false, diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index cfd61b14a..1ae0337cf 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -81,7 +81,7 @@ export const casesExhausted = (value: never): never => { throw new Error(`Unhandled case: ${String(value)}`) } -export const isPlainObject = (value: unknown): value is object => { +export const isPlainObject = (value: unknown): value is Record => { return typeof value === `object` && value !== null && !Array.isArray(value) } @@ -233,3 +233,7 @@ export const mapValues = < export type SetProperty<$Obj extends object, $Prop extends keyof $Obj, $Type extends $Obj[$Prop]> = & Omit<$Obj, $Prop> & { [_ in $Prop]: $Type } + +export const lowerCaseFirstLetter = (s: string) => { + return s.charAt(0).toLowerCase() + s.slice(1) +} diff --git a/tests/_/db.ts b/tests/_/db.ts index 753509628..da1713d8c 100644 --- a/tests/_/db.ts +++ b/tests/_/db.ts @@ -8,6 +8,9 @@ const error = new Errors.ContextualAggregateError(`One or more errors in the exe new GraphQLError(`Something went wrong.`), ]) +const ErrorOneError = new Error(`Failure on field result: ErrorOne`) +const ErrorTwoError = new Error(`Failure on field result: ErrorTwo`) + const id = `abc` const int = 123 @@ -41,6 +44,8 @@ export const db = { ABCEnum: `A`, ErrorOne: { message: `errorOne`, infoId: id }, ErrorTwo: { message: `errorOne`, infoInt: int }, + ErrorOneError, + ErrorTwoError, int, id, id1: id, diff --git a/tests/_/schema/generated/Index.ts b/tests/_/schema/generated/Index.ts index e5b51a663..7ef5ed409 100644 --- a/tests/_/schema/generated/Index.ts +++ b/tests/_/schema/generated/Index.ts @@ -3,6 +3,7 @@ import type * as Schema from './SchemaBuildtime.js' export interface Index { + name: 'default' Root: { Query: Schema.Root.Query Mutation: Schema.Root.Mutation @@ -39,5 +40,16 @@ export interface Index { ErrorOne: Schema.Object.ErrorOne ErrorTwo: Schema.Object.ErrorTwo } + objectsTypename: { + ErrorOne: { __typename: 'ErrorOne' } + ErrorTwo: { __typename: 'ErrorTwo' } + } + rootResultFields: { + Query: { + result: 'result' + } + Mutation: {} + Subscription: {} + } } } diff --git a/tests/_/schema/generated/SchemaRuntime.ts b/tests/_/schema/generated/SchemaRuntime.ts index f1fcc01ca..69f02dee6 100644 --- a/tests/_/schema/generated/SchemaRuntime.ts +++ b/tests/_/schema/generated/SchemaRuntime.ts @@ -203,6 +203,7 @@ export const Query = $.Object$(`Query`, { }) export const $Index = { + name: 'default' as const, Root: { Query, Mutation, @@ -239,5 +240,16 @@ export const $Index = { ErrorOne, ErrorTwo, }, + objectsTypename: { + ErrorOne: { __typename: 'ErrorOne' }, + ErrorTwo: { __typename: 'ErrorTwo' }, + }, + rootResultFields: { + Query: { + result: 'result' as const, + }, + Mutation: {}, + Subscription: {}, + }, }, } diff --git a/tests/_/schemaMutationOnly/generated/Index.ts b/tests/_/schemaMutationOnly/generated/Index.ts index 883eed113..a3ca1101e 100644 --- a/tests/_/schemaMutationOnly/generated/Index.ts +++ b/tests/_/schemaMutationOnly/generated/Index.ts @@ -3,6 +3,7 @@ import type * as Schema from './SchemaBuildtime.js' export interface Index { + name: 'MutationOnly' Root: { Query: null Mutation: Schema.Root.Mutation @@ -13,5 +14,11 @@ export interface Index { interfaces: {} error: { objects: {} + objectsTypename: {} + rootResultFields: { + Query: {} + Mutation: {} + Subscription: {} + } } } diff --git a/tests/_/schemaMutationOnly/generated/SchemaRuntime.ts b/tests/_/schemaMutationOnly/generated/SchemaRuntime.ts index 2720357b9..91f87dc75 100644 --- a/tests/_/schemaMutationOnly/generated/SchemaRuntime.ts +++ b/tests/_/schemaMutationOnly/generated/SchemaRuntime.ts @@ -10,6 +10,7 @@ export const Mutation = $.Object$(`Mutation`, { }) export const $Index = { + name: 'MutationOnly' as const, Root: { Query: null, Mutation, @@ -20,5 +21,11 @@ export const $Index = { interfaces: {}, error: { objects: {}, + objectsTypename: {}, + rootResultFields: { + Query: {}, + Mutation: {}, + Subscription: {}, + }, }, } diff --git a/tests/_/schemaQueryOnly/generated/Index.ts b/tests/_/schemaQueryOnly/generated/Index.ts index 4ac156d9a..10019219e 100644 --- a/tests/_/schemaQueryOnly/generated/Index.ts +++ b/tests/_/schemaQueryOnly/generated/Index.ts @@ -3,6 +3,7 @@ import type * as Schema from './SchemaBuildtime.js' export interface Index { + name: 'QueryOnly' Root: { Query: Schema.Root.Query Mutation: null @@ -13,5 +14,11 @@ export interface Index { interfaces: {} error: { objects: {} + objectsTypename: {} + rootResultFields: { + Query: {} + Mutation: {} + Subscription: {} + } } } diff --git a/tests/_/schemaQueryOnly/generated/SchemaRuntime.ts b/tests/_/schemaQueryOnly/generated/SchemaRuntime.ts index 8b983dc21..e9af4a0c0 100644 --- a/tests/_/schemaQueryOnly/generated/SchemaRuntime.ts +++ b/tests/_/schemaQueryOnly/generated/SchemaRuntime.ts @@ -10,6 +10,7 @@ export const Query = $.Object$(`Query`, { }) export const $Index = { + name: 'QueryOnly' as const, Root: { Query, Mutation: null, @@ -20,5 +21,11 @@ export const $Index = { interfaces: {}, error: { objects: {}, + objectsTypename: {}, + rootResultFields: { + Query: {}, + Mutation: {}, + Subscription: {}, + }, }, }