Skip to content

Commit

Permalink
feat(raw): support typename injection (#1156)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Oct 2, 2024
1 parent 0f33ce9 commit 3c0a60c
Show file tree
Hide file tree
Showing 10 changed files with 146 additions and 100 deletions.
11 changes: 6 additions & 5 deletions src/layers/3_ResultSet/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,17 @@ const getAliasesField = (fieldName: string, ss: Select.SelectionSet.AnySelection
const getDataFieldInSelectionSet = (
fieldName: string,
selectionSet: Select.SelectionSet.AnySelectionSet,
): {
): null | {
fieldName: string
selectionSet: Select.SelectionSet.AnyExceptAlias
} => {
const result = getDataFieldInSelectionSet_(fieldName, selectionSet)
if (result) return result

throw new Error(
`Cannot decode field "${fieldName}" in result data. That field was not found in the selection set.`,
)
return null
// throw new Error(
// `Cannot decode field "${fieldName}" in result data. That field was not found in the selection set.`,
// )
}

const getDataFieldInSelectionSet_ = (
Expand Down Expand Up @@ -104,10 +105,10 @@ export const decode = <$Data extends ExecutionResult['data']>(

return mapValues(data, (value, fieldName) => {
const selectionSetField = getDataFieldInSelectionSet(fieldName, selectionSet)
if (!selectionSetField) return value

const schemaField = objectType.fields[selectionSetField.fieldName]
if (!schemaField) throw new Error(`Field not found in schema: ${String(selectionSetField.fieldName)}`)

const schemaFieldType = readMaybeThunk(schemaField.type)
const schemaFieldTypeSansNonNull = Output.unwrapNullable(schemaFieldType) as Output.Named | Output.List<any>
const v2 = decodeCustomScalarValue(schemaFieldTypeSansNonNull, selectionSetField.selectionSet, value as any)
Expand Down
2 changes: 1 addition & 1 deletion src/layers/3_SelectionSetGraphqlMapper/_.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { toGraphQLDocument } from './nodes/Document.js'
export { toGraphQL } from './helpers.js'
20 changes: 20 additions & 0 deletions src/layers/3_SelectionSetGraphqlMapper/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Schema } from '../1_Schema/__.js'
import type { Select } from '../2_Select/__.js'
import { toGraphQLDocument } from './nodes/Document.js'

export const toGraphQL = (input: {
schema: Schema.Index
document: Select.Document.DocumentNormalized
}) => {
return toGraphQLDocument(
{
schema: input.schema,
captures: {
customScalarOutputs: [],
variables: [],
},
},
[],
input.document,
)
}
6 changes: 5 additions & 1 deletion src/layers/3_SelectionSetGraphqlMapper/nodes/Argument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { toGraphQLValue } from './Value.js'

export const toGraphQLArgument: GraphQLNodeMapper<
Nodes.ArgumentNode,
[arg: { name: string; type: Schema.Input.Any; value: Select.Arguments.ArgValue }]
[arg: {
name: string
type: Schema.Input.Any
value: Select.Arguments.ArgValue
}]
> = (
context,
location,
Expand Down
76 changes: 42 additions & 34 deletions src/layers/5_core/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ExecutionResult, print } from 'graphql'
import { type ExecutionResult, parse, print } from 'graphql'
import { Anyware } from '../../lib/anyware/__.js'
import {
OperationTypeAccessTypeMap,
Expand Down Expand Up @@ -27,7 +27,7 @@ import {
type MethodModeGetReads,
} from '../6_client/transportHttp/request.js'
import { type HookMap, hookNamesOrderedBySequence, type HookSequence } from './hooks.js'
import { injectTypenameOnResultFields } from './schemaErrors.js'
import { injectTypenameOnRootResultFields } from './schemaErrors.js'

export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
// If core errors caused by an abort error then raise it as a direct error.
Expand All @@ -41,55 +41,57 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
hookNamesOrderedBySequence,
hooks: {
encode: ({ input }) => {
let document: string
let variables: StandardScalarVariables | undefined = undefined
let documentString: string

switch (input.interface) {
case `raw`: {
const documentPrinted = typeof input.document === `string`
const isWillInjectTypename = input.context.config.output.errors.schema && input.context.schemaIndex

if (isWillInjectTypename) {
const documentObject = input.interface === `raw`
? typeof input.document === `string`
? parse(input.document)
: input.document
: SelectionSetGraphqlMapper.toGraphQL({
schema: input.context.schemaIndex,
document: input.document,
})

injectTypenameOnRootResultFields({
document: documentObject,
operationName: input.operationName,
schema: input.context.schemaIndex!,
})

documentString = print(documentObject)
} else {
documentString = input.interface === `raw`
? typeof input.document === `string`
? input.document
: print(input.document)
document = documentPrinted
variables = input.variables
break
}
case `typed`: {
// todo turn inputs into variables
variables = undefined
document = print(SelectionSetGraphqlMapper.toGraphQLDocument(
{
schema: input.context.schemaIndex,
captures: { customScalarOutputs: [], variables: [] },
},
[],
input.context.config.output.errors.schema
? injectTypenameOnResultFields({
operationName: input.operationName,
schema: input.context.schemaIndex,
document: input.document,
})
: input.document,
))
break
}
default:
throw casesExhausted(input)
: print(SelectionSetGraphqlMapper.toGraphQL({
schema: input.context.schemaIndex,
document: input.document,
}))
}

const variables: StandardScalarVariables | undefined = input.interface === `raw`
? input.variables
// todo turn inputs into variables
: undefined

switch (input.transport) {
case `http`: {
return {
...input,
url: input.schema,
query: document,
query: documentString,
variables,
}
}
case `memory`: {
return {
...input,
schema: input.schema,
query: document,
query: documentString,
variables,
}
}
Expand Down Expand Up @@ -220,6 +222,12 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
throw casesExhausted(input)
}
},
// todo
// Given that we manipulate the selection set in encode, and given decode relies on the sent selection set
// it follows that the decode hook depends on the output of the encode hook. that means we need to plumb
// through the hooks that data built during encode. Yet encode doesn't output it currently, but rather prints it.
// Hooks could have a new optional field "schema". When present certain enhanced features would be allowed.
// like custom scalars and result fields.
decode: ({ input }) => {
switch (input.interface) {
// todo this depends on the return mode
Expand Down
40 changes: 32 additions & 8 deletions src/layers/5_core/schemaErrors.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { expect, test } from 'vitest'
import { expect } from 'vitest'
import { test } from '../../../tests/_/helpers.js'
import { $Index as schema } from '../../../tests/_/schemas/kitchen-sink/graffle/modules/SchemaRuntime.js'
import type { Query } from '../../../tests/_/schemas/kitchen-sink/graffle/modules/SelectionSets.js'
import { Select } from '../2_Select/__.js'
import { injectTypenameOnResultFields } from './schemaErrors.js'
import { SelectionSetGraphqlMapper } from '../3_SelectionSetGraphqlMapper/__.js'
import { gql } from '../6_helpers/gql.js'
import { Throws } from '../7_extensions/Throws/Throws.js'
import { injectTypenameOnRootResultFields } from './schemaErrors.js'

type CasesQuery = [description: string, queryWithoutTypename: Query, queryWithTypename: Query]

Expand All @@ -17,13 +21,33 @@ test.each<CasesQuery>([
[`root field in fragment in alias`, { ___: { resultNonNull: [`x`, {}] } }, { ___: { resultNonNull: [`x`, { __typename: true }] }}],
[`root field alias `, { resultNonNull: [`x`, {}] }, { resultNonNull: [`x`, { __typename: true }] }],
])(`Query %s`, (_, queryWithoutTypenameInput, queryWithTypenameInput) => {
const documentWithTypename = Select.Document.normalizeOrThrow({ query: { x: queryWithTypenameInput as any } })
const documentWithoutTypename = Select.Document.normalizeOrThrow({ query: { x: queryWithoutTypenameInput as any } })

injectTypenameOnResultFields({
document:documentWithoutTypename,
const documentWithTypename = SelectionSetGraphqlMapper.toGraphQL({
schema,
document: Select.Document.normalizeOrThrow({ query: { x: queryWithTypenameInput as any } })
})
const documentWithoutTypename = SelectionSetGraphqlMapper.toGraphQL({
schema,
document: Select.Document.normalizeOrThrow({ query: { x: queryWithoutTypenameInput as any } })
})
injectTypenameOnRootResultFields({
document: documentWithoutTypename,
schema,
})

expect(documentWithoutTypename).toMatchObject(documentWithTypename)
})

test(`type name field injection works for raw string requests`, async ({ kitchenSink }) => {
// todo it would be nicer to move the extension use to the fixture but how would we get the static type for that?
// This makes me think of a feature we need to have. Make it easy to get static types of the client in its various configured states.
const result = await kitchenSink.use(Throws()).throws().rawString({
document: `query { resultNonNull (case: Object1) { ... on Object1 { id } } }`,
})
expect(result).toMatchObject({ resultNonNull: { __typename: `Object1`, id: `abc` } })
})

test(`type name field injection works for raw document requests`, async ({ kitchenSink }) => {
const result = await kitchenSink.use(Throws()).throws().raw({
document: gql`query { resultNonNull (case: Object1) { ... on Object1 { id } } }`,
})
expect(result).toMatchObject({ resultNonNull: { __typename: `Object1`, id: `abc` } })
})
78 changes: 28 additions & 50 deletions src/layers/5_core/schemaErrors.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,51 @@
import type { RootTypeName } from '../../lib/graphql-plus/graphql.js'
import { Nodes, operationTypeNameToRootTypeName, type RootTypeName } from '../../lib/graphql-plus/graphql.js'
import type { Schema } from '../1_Schema/__.js'
import { Select } from '../2_Select/__.js'

export const injectTypenameOnResultFields = (
input: {
export const injectTypenameOnRootResultFields = (
{ document, operationName, schema }: {
operationName?: string | undefined
schema: Schema.Index
document: Select.Document.DocumentNormalized
document: Nodes.DocumentNode
},
): Select.Document.DocumentNormalized => {
const { document, operationName, schema } = input
const operation = operationName ? document.operations[operationName] : Object.values(document.operations)[0]!
): void => {
const operationDefinition = document.definitions.find(_ =>
_.kind === Nodes.Kind.OPERATION_DEFINITION && (operationName ? _.name?.value === operationName : true)
) as Nodes.OperationDefinitionNode | undefined

if (!operation) {
if (!operationDefinition) {
throw new Error(`Operation not found`)
}

injectTypenameOnRootResultFields({
rootTypeName: operation.rootType,
injectTypenameOnRootResultFields_({
rootTypeName: operationTypeNameToRootTypeName[operationDefinition.operation],
schema,
selectionSet: operation.selectionSet,
selectionSet: operationDefinition.selectionSet,
})

return document
}

const injectTypenameOnRootResultFields = (
input: {
const injectTypenameOnRootResultFields_ = (
{ selectionSet, schema, rootTypeName }: {
schema: Schema.Index
selectionSet: Select.SelectionSet.AnySelectionSet
rootTypeName: RootTypeName
selectionSet: Nodes.SelectionSetNode
},
): void => {
const { selectionSet, schema, rootTypeName } = input

for (const [rootFieldName, fieldValue] of Object.entries(selectionSet)) {
const field = Select.parseSelection(rootFieldName, fieldValue)

switch (field.type) {
case `InlineFragment`: {
// we need to check contents for result root fields
for (const inlineFragmentSelectionSet of field.selectionSets) {
injectTypenameOnRootResultFields({
rootTypeName,
schema,
selectionSet: inlineFragmentSelectionSet,
})
for (const selection of selectionSet.selections) {
switch (selection.kind) {
case Nodes.Kind.FIELD: {
if (schema.error.rootResultFields[rootTypeName][selection.name.value]) {
// @ts-expect-error selections is typed as readonly
// @see https://github.com/graphql/graphql-js/discussions/4212
selection.selectionSet?.selections.push(Nodes.Field({ name: Nodes.Name({ value: `__typename` }) }))
}
continue
}
case `SelectionSet`: {
if (schema.error.rootResultFields[rootTypeName][rootFieldName]) {
field.selectionSet[`__typename`] = true
}
continue
}
case `Alias`: {
if (schema.error.rootResultFields[rootTypeName][rootFieldName]) {
for (const alias of field.aliases) {
// Casting type: This alias is for a field whose type is in rootResultFields
// so it must be a selection set (e.g. not an indicator)
const aliasSelectionSet = alias[1] as Select.SelectionSet.AnySelectionSet
aliasSelectionSet[`__typename`] = true
}
}
continue
}
default: {
continue
case Nodes.Kind.INLINE_FRAGMENT: {
injectTypenameOnRootResultFields_({
rootTypeName,
schema,
selectionSet: selection.selectionSet,
})
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/layers/6_client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type SelectionSetOrArgs = object
export interface RequestContext {
config: Config
state: State
schemaIndex: Schema.Index | null
}

export interface InterfaceTypedRequestContext extends RequestContext {
Expand Down Expand Up @@ -128,6 +129,7 @@ const createWithState = (
// @ts-expect-error fixme
config: inputToConfig(state.input),
state,
schemaIndex: state.input.schemaIndex ?? null,
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/layers/7_extensions/Throws/Throws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe(`document`, () => {
})

test(`.raw() throws if errors array non-empty`, async () => {
await expect(graffle.throws().rawString({ document: `query {}` })).rejects.toMatchInlineSnapshot(
await expect(graffle.throws().rawString({ document: `query { foo }` })).rejects.toMatchInlineSnapshot(
`[ContextualAggregateError: One or more errors in the execution result.]`,
)
})
Expand Down
Loading

0 comments on commit 3c0a60c

Please sign in to comment.