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

feat(raw): support typename injection #1156

Merged
merged 1 commit into from
Oct 2, 2024
Merged
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
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