diff --git a/README.md b/README.md index a8f4928..bf3a41f 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ graphql-intuitive-request provides an **intuitive** and **TS-friendly** way to w ```typescript import { createClient, enumOf } from 'graphql-intuitive-request'; -const { query } = createClient('https://example.com/graphql').withSchema({ +const { mutation, query } = createClient('https://example.com/graphql').withSchema({ User: { id: 'Int!', username: 'String!', email: 'String', - posts: '[Post!]!', + posts: [{ 'first?': 'Int!' }, '[Post!]!'], // Field with arguments }, Post: { id: 'Int!', @@ -26,12 +26,15 @@ const { query } = createClient('https://example.com/graphql').withSchema({ author: 'User!', coAuthors: '[User!]!', }, - PostStatus: enumOf('DRAFT', 'PUBLISHED', 'ARCHIVED'), + PostStatus: enumOf('DRAFT', 'PUBLISHED', 'ARCHIVED'), // Enum type Query: { users: ['=>', '[User!]!'], post: [{ id: 'Int!' }, '=>', 'Post'], }, + Mutation: { + removePost: [{ id: 'Int!' }, '=>', 'Boolean!'], + }, }); /* @@ -49,7 +52,7 @@ const { query } = createClient('https://example.com/graphql').withSchema({ const allUsers = await query('users').select((user) => [ user.id, user.username, - user.posts((post) => [ + user.posts({ first: 10 }, (post) => [ post.title, post.status, post.coAuthors((author) => [author.id, author.username]), @@ -73,8 +76,9 @@ const post = await query('post', { id: 1 }).select((post) => [ post.author((author) => [author.username]), post.coAuthors((author) => [author.username]), ]); +await mutation('removePost').byId(1); // ... or use `.by` method and its variants -const post = await query('post') +const post2 = await query('post') .select((post) => [ post.title, post.status, @@ -103,6 +107,35 @@ which is **exactly** what we want, not just a generic object like `User[]`! ![Exact Type Inference with TypeScript](./docs/img/exact-type-inference.png) +The schema is equivalent to the following GraphQL schema: + +```graphql +type Query { + users: [User!]! + post(id: Int!): Post +} + +type User { + id: Int! + username: String! + email: String + posts(first: Int): [Post!]! +} + +type Post { + id: Int! + status: PostStatus! + title: String! + content: String! +} + +enum PostStatus { + DRAFT + PUBLISHED + ARCHIVED +} +``` + The syntax is almost the same as the one used in GraphQL: - Types are defined in the `withSchema` function. @@ -224,6 +257,106 @@ setTimeout(async () => { For more details, you can check [the relevant test file](./test/client.spec.ts). +### Query field with arguments + +graphql-intuitive-request supports querying fields with arguments. Say you have the following GraphQL schema: + +```graphql +type Query { + user(id: Int!): User +} + +type User { + id: Int! + name: String! + posts(status: PostStatus): [Post!]! +} + +enum PostStatus { + DRAFT + PUBLISHED + ARCHIVED +} + +type Post { + id: Int! + title: String! + content: String! +} +``` + +How to represent `posts(status: PostStatus)` in the `withSchema` function? You can represent such a field with arguments as a 2-element tuple, where the first element is an object representing the input type of the field, and the second element is the return type of the field. + +```typescript +import { createClient, enumOf } from 'graphql-intuitive-request'; + +const { query } = createClient('https://example.com/graphql').withSchema({ + User: { + id: 'Int!', + name: 'String!', + posts: [{ status: 'PostStatus' }, '[Post!]!'], + }, + Post: { + id: 'Int!', + title: 'String!', + content: 'String!', + }, + PostStatus: enumOf('DRAFT', 'PUBLISHED', 'ARCHIVED'), + + Query: { + user: [{ id: 'Int!' }, '=>', 'User'], + }, +}); +``` + +Note that we use `[{ status: 'PostStatus' }, '[Post!]!']` instead of `['{ status: PostStatus }', '=>', '[Post!]!']` (which is used in operation definitions like `Query`). This is to make operation definitions more concise and readable. + +Then you can query the `posts` field like this: + +```typescript +const user = await query('user', { id: 1 }).select((user) => [ + user.id, + user.name, + user.posts({ status: 'PUBLISHED' }, (post) => [post.id, post.title]), +]); +``` + +This is equivalent to the following GraphQL query: + +```graphql +query user($id: Int!, $postStatus: PostStatus) { + user(id: $id) { + id + name + posts(status: $postStatus) { + id + title + } + } +} +``` + +...with the following variables: + +```json +{ + "id": 1, + "postStatus": "PUBLISHED" +} +``` + +Since all arguments of `posts` are optional, you can still query the `posts` field without arguments: + +```typescript +const user = await query('user', { id: 1 }).select((user) => [ + user.id, + user.name, + user.posts((post) => [post.id, post.title]), +]); +``` + +Whether you can omit arguments or not is validated at compile time, so you can be sure that you will not forget to pass required arguments at runtime. + ### Support for subscriptions graphql-intuitive-request supports GraphQL subscriptions. You can use `client.subscription` to create a subscription. The usage is similar to `client.query` and `client.mutation`. diff --git a/src/client.ts b/src/client.ts index 8120e84..f0ef9e5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,7 +11,7 @@ import { capitalize, mapObject, mapObjectValues, omit, pick, requiredKeysCount } import type { schema } from './types'; import type { MutationFunction, QueryFunction, SubscriptionFunction } from './types/client-tools'; import type { Obj, QueryPromise, SimpleSpread, SubscriptionResponse } from './types/common'; -import type { FunctionCollection, TypeCollection } from './types/graphql-types'; +import type { OperationCollection, TypeCollection } from './types/graphql-types'; import type { QueryNode } from './types/query-node'; import type { ValidateSchema } from './types/validator'; import type { Client as WSClient, ClientOptions as WSClientOptions } from 'graphql-ws'; @@ -59,24 +59,24 @@ const _createClient = < const _rawTypes = rawTypes as | TypeCollection | { - Query?: FunctionCollection; - Mutation?: FunctionCollection; - Subscription?: FunctionCollection; + Query?: OperationCollection; + Mutation?: OperationCollection; + Subscription?: OperationCollection; }; const $ = omit(_rawTypes, 'Query', 'Mutation', 'Subscription') as TypeCollection; type NormalizedOperations = ReturnType; - const normalizeOperations = (collection: FunctionCollection) => + const normalizeOperations = (collection: OperationCollection) => mapObjectValues(collection, (value) => value.length === 3 ? { inputType: value[0], returnType: value[2] } : { inputType: {}, returnType: value[1] }, ); - const queries = normalizeOperations((_rawTypes.Query as FunctionCollection) ?? {}); - const mutations = normalizeOperations((_rawTypes.Mutation as FunctionCollection) ?? {}); - const subscriptions = normalizeOperations((_rawTypes.Subscription as FunctionCollection) ?? {}); + const queries = normalizeOperations((_rawTypes.Query as OperationCollection) ?? {}); + const mutations = normalizeOperations((_rawTypes.Mutation as OperationCollection) ?? {}); + const subscriptions = normalizeOperations((_rawTypes.Subscription as OperationCollection) ?? {}); const typeParser = createTypeParser($); const compileOperations = (operations: NormalizedOperations) => @@ -98,10 +98,39 @@ const _createClient = < method: TMethod, operationName: string, inputType: Record, + returnType: string, input: Record, - ast: readonly QueryNode[], + ast: readonly QueryNode[] | (() => readonly QueryNode[]), ): TMethod extends 'subscription' ? SubscriptionResponse : QueryPromise => { - const queryString = buildQueryString(method, operationName, inputType, ast); + const { queryString, variables } = (() => { + let cached: ReturnType | null = null; + return { + queryString: () => { + if (cached) return cached.queryString; + cached = buildQueryString( + method, + operationName, + inputType, + returnType, + $, + typeof ast === 'function' ? ast() : ast, + ); + return cached.queryString; + }, + variables: () => { + if (cached) return cached.variables; + cached = buildQueryString( + method, + operationName, + inputType, + returnType, + $, + typeof ast === 'function' ? ast() : ast, + ); + return cached.variables; + }, + }; + })(); if (method === 'subscription') return { @@ -111,7 +140,7 @@ const _createClient = < onComplete?: () => void, ) => wsClient.subscribe( - { query: queryString, variables: input }, + { query: queryString(), variables: { ...input, ...variables() } }, { next: (value) => { if (value.errors) onError?.(value.errors); @@ -125,17 +154,22 @@ const _createClient = < }, }, ), - toQueryString: () => queryString, - toRequestBody: () => ({ query: queryString, variables: input }), + toQueryString: () => queryString(), + toRequestBody: () => ({ query: queryString(), variables: { ...input, ...variables() } }), } as any; const result: any = Promise.resolve(null).then(() => { if (cancelledPromises.has(result)) return; - return requestClient.request(queryString, input).then((data) => (data as any)[operationName]); + return requestClient + .request(queryString(), { ...input, ...variables() }) + .then((data) => (data as any)[operationName]); + }); + result.toQueryString = () => queryString(); + result.toRequestBody = () => ({ + query: queryString(), + variables: { ...input, ...variables() }, }); - result.toQueryString = () => queryString; - result.toRequestBody = () => ({ query: queryString, variables: input }); return result; }; @@ -156,16 +190,18 @@ const _createClient = < let result: any = {}; if (!operation.hasInput || input !== undefined) { - const ast = operation.isReturnTypeScalar - ? [] - : parseSelector(createAllSelector(operation.returnType, $)); + const ast = () => + operation.isReturnTypeScalar + ? [] + : parseSelector(createAllSelector(operation.returnType, $)); result = input === undefined - ? buildOperationResponse(method, operationName, {}, {}, ast) + ? buildOperationResponse(method, operationName, {}, operation.returnType, {}, ast) : buildOperationResponse( method, operationName, pick(operation.inputType, ...Object.keys(input)), + operation.returnType, input, ast, ); @@ -175,21 +211,26 @@ const _createClient = < if (operation.hasInput && input === undefined) { if (requiredKeysCount(rawOperation.inputType) === 0) { - const ast = operation.isReturnTypeScalar - ? [] - : parseSelector(createAllSelector(operation.returnType, $)); - result = buildOperationResponse(method, operationName, {}, {}, ast); + // eslint-disable-next-line sonarjs/no-identical-functions + const ast = () => + operation.isReturnTypeScalar + ? [] + : parseSelector(createAllSelector(operation.returnType, $)); + result = buildOperationResponse(method, operationName, {}, operation.returnType, {}, ast); } result.by = (input: any) => { cancelledPromises.add(result); - const ast = operation.isReturnTypeScalar - ? [] - : parseSelector(createAllSelector(operation.returnType, $)); + // eslint-disable-next-line sonarjs/no-identical-functions + const ast = () => + operation.isReturnTypeScalar + ? [] + : parseSelector(createAllSelector(operation.returnType, $)); return buildOperationResponse( method, operationName, pick(operation.inputType, ...Object.keys(input)), + operation.returnType, input, ast, ); @@ -213,11 +254,12 @@ const _createClient = < cancelledPromises.add(result); const ast = parseSelector(selector); return input === undefined - ? buildOperationResponse(method, operationName, {}, {}, ast) + ? buildOperationResponse(method, operationName, {}, operation.returnType, {}, ast) : buildOperationResponse( method, operationName, pick(operation.inputType, ...Object.keys(input)), + operation.returnType, input, ast, ); @@ -228,7 +270,7 @@ const _createClient = < const by = result.by; const select = result.select; const ast = parseSelector(selector); - result = buildOperationResponse(method, operationName, {}, {}, ast); + result = buildOperationResponse(method, operationName, {}, operation.returnType, {}, ast); if (by) result.by = by; result.select = select; } @@ -241,6 +283,7 @@ const _createClient = < method, operationName, pick(operation.inputType, ...Object.keys(input)), + operation.returnType, input, ast, ); diff --git a/src/query-builder.ts b/src/query-builder.ts index 8c3f402..d6c3c54 100644 --- a/src/query-builder.ts +++ b/src/query-builder.ts @@ -1,31 +1,89 @@ import { parseSelector } from './selector'; +import { createTypeParser } from './type-parser'; +import { mapObjectValues } from './utils'; import type { ObjectSelector } from './types/ast-builder'; +import type { TypeCollection } from './types/graphql-types'; import type { QueryNode } from './types/query-node'; -const buildQueryAst = (ast: readonly QueryNode[], indent: number = 4): string => - `{\n${ast +const path2variableName = (path: readonly string[]) => '__var__' + path.join('__sep__'); + +const buildQueryAst = ( + ast: readonly QueryNode[], + indent: number, + type: string, + $: TypeCollection, + path: readonly string[] = [], +): { queryString: string; variables: Record } => { + let _variables: Record = {}; + const queryString = `{\n${ast .map( (node) => - `${' '.repeat(indent)}${ + `${' '.repeat(indent)}${node.key}${ + Object.keys(node.args).length > 0 + ? '(' + + Object.entries(node.args) + .map(([key, value]) => { + const variableName = path2variableName([...path, node.key, key]); + const valueType = $[type][node.key as never][0][key] as string; + _variables[variableName] = { type: valueType, value }; + return `${key}: $${variableName}`; + }) + .join(', ') + + ')' + : '' + }${ node.children !== null - ? `${node.key} ${buildQueryAst(node.children as QueryNode[], indent + 2)}` - : node.key + ? ' ' + + (() => { + const { queryString, variables } = buildQueryAst( + node.children as QueryNode[], + indent + 2, + createTypeParser($).extractCoreType( + Array.isArray($[type][node.key as never]) + ? $[type][node.key as never][1] + : $[type][node.key as never], + ), + $, + [...path, node.key], + ); + _variables = { ..._variables, ...variables }; + return queryString; + })() + : '' }`, ) .join('\n')}\n${' '.repeat(indent - 2)}}`; + return { queryString, variables: _variables }; +}; export const buildQueryString = ( type: 'query' | 'mutation' | 'subscription', name: string, inputType: Record, + returnType: string, + $: TypeCollection, ast: readonly QueryNode[], -) => { +): { queryString: string; variables: Record } => { + const [operationBody, variables] = + ast.length > 0 + ? (() => { + const { queryString, variables } = buildQueryAst( + ast, + 4, + createTypeParser($).extractCoreType(returnType), + $, + ); + return [' ' + queryString, variables]; + })() + : ['', {}]; const definitionHeader = `${type} ${name}${ - Object.keys(inputType).length > 0 - ? `(${Object.entries(inputType) - .map(([key, value]) => `$${key}: ${value}`) - .join(', ')})` + Object.keys({ ...inputType, ...variables }).length > 0 + ? '(' + + Object.entries({ ...inputType, ...variables }) + .map(([key, value]) => `$${key}: ${typeof value === 'string' ? value : value.type}`) + .join(', ') + + ')' : '' } {`; const operationHeader = `${name}${ @@ -35,27 +93,31 @@ export const buildQueryString = ( .join(', ')})` : '' }`; - const operationBody = ast.length > 0 ? ` ${buildQueryAst(ast)}` : ''; - return `${definitionHeader}\n ${operationHeader}${operationBody}\n}`; + + const queryString = `${definitionHeader}\n ${operationHeader}${operationBody}\n}`; + return { queryString, variables: mapObjectValues(variables, (v) => v.value) }; }; +const createInfiniteGetter = (): any => new Proxy({}, { get: () => createInfiniteGetter() }); + const operationString = (type: 'query' | 'mutation' | 'subscription') => (name: string) => ({ input: (inputType: Record) => ({ select: (selector: ObjectSelector) => ({ build: () => { const ast = parseSelector(selector); - return buildQueryString(type, name, inputType, ast); + return buildQueryString(type, name, inputType, '', createInfiniteGetter(), ast).queryString; }, }), - build: () => buildQueryString(type, name, inputType, []), + build: () => + buildQueryString(type, name, inputType, '', createInfiniteGetter(), []).queryString, }), select: (selector: ObjectSelector) => ({ build: () => { const ast = parseSelector(selector); - return buildQueryString(type, name, {}, ast); + return buildQueryString(type, name, {}, '', createInfiniteGetter(), ast).queryString; }, }), - build: () => buildQueryString('query', name, {}, []), + build: () => buildQueryString('query', name, {}, '', createInfiniteGetter(), []).queryString, }); /** diff --git a/src/selector.ts b/src/selector.ts index 8219bf4..86b589c 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,8 +1,9 @@ import { createTypeParser } from './type-parser'; +import { requiredKeysCount } from './utils'; import type { ObjectSelector, ObjectSelectorBuilder } from './types/ast-builder'; import type { ParseNodes } from './types/ast-parser'; -import type { TypeCollection } from './types/graphql-types'; +import type { ObjectDefinition, TypeCollection } from './types/graphql-types'; import type { QueryNode } from './types/query-node'; const createBuilder = (): ObjectSelectorBuilder => @@ -10,11 +11,18 @@ const createBuilder = (): ObjectSelectorBuilder => {}, { get: (_, prop) => { - const result: any = (selector: any) => { + const result: any = ( + ...as: + | [input: Record, selector: ObjectSelector] + | [selector: ObjectSelector] + ) => { + const [args, selector] = as.length === 1 ? [{}, as[0]] : as; + result.args = args; result.children = selector(createBuilder()); return result; }; result.key = prop; + result.args = {}; result.children = null; return result; }, @@ -27,12 +35,16 @@ export const createAllSelector = ( ): any => { const parser = createTypeParser($); - const spread = (coreType: string) => $[coreType] as Record; + const spread = (coreType: string) => $[coreType] as ObjectDefinition; const buildSelector = (coreType: string) => (o: any) => { return Object.entries(spread(coreType)).map(([key, value]) => { - const coreType = parser.extractCoreType(value); - return parser.isScalarType(coreType) ? o[key] : o[key](buildSelector(coreType)); + if (typeof value !== 'string' && requiredKeysCount(value[0]) > 0) + throw new Error( + `All input fields of '${coreType}.${key}' must be optional to automatically select all fields`, + ); + const _coreType = parser.extractCoreType(typeof value === 'string' ? value : value[1]); + return parser.isScalarType(_coreType) ? o[key] : o[key](buildSelector(_coreType)); }); }; diff --git a/src/types/ast-builder.ts b/src/types/ast-builder.ts index 970cdf3..9462484 100644 --- a/src/types/ast-builder.ts +++ b/src/types/ast-builder.ts @@ -6,6 +6,8 @@ import type { IsFunction, IsNever, IsUnknown, + Obj, + StringKeyOf, } from './common'; import type { ArrayQueryNode, @@ -18,7 +20,7 @@ import type { } from './query-node'; export type ObjectSelectorBuilder = ExcludeNeverValues<{ - [K in Exclude]: GetQueryNode; + [K in StringKeyOf]: GetQueryNode; }>; export type ObjectSelector = ( @@ -37,6 +39,7 @@ type GetQueryNode< K extends string, V, TIsLastNullable extends boolean = false, + TLastInput = never, > = IsAny extends true ? unknown : IsUnknown extends true @@ -46,47 +49,142 @@ type GetQueryNode< : IsFunction extends true ? never : CanBeUndefined extends true - ? GetQueryNode, true> extends infer R + ? GetQueryNode, true, TLastInput> extends infer R ? R extends (...args: any[]) => unknown ? R - : NullableQueryNode + : IsNever extends true + ? NullableQueryNode + : Obj.IsAllOptional extends true + ? NullableQueryNode & ((input: TLastInput) => NullableQueryNode) + : (input: TLastInput) => NullableQueryNode : never : CanBeNull extends true - ? GetQueryNode, true> extends infer R + ? GetQueryNode, true, TLastInput> extends infer R ? R extends (...args: any[]) => unknown ? R - : NullableQueryNode + : IsNever extends true + ? NullableQueryNode + : Obj.IsAllOptional extends true + ? NullableQueryNode & ((input: TLastInput) => NullableQueryNode) + : (input: TLastInput) => NullableQueryNode : never + : [V] extends [[infer TInput, infer TOutput]] + ? GetQueryNode : [V] extends [Array] ? IsPrimitiveOrNestedPrimitiveArray extends true ? GetQueryNode extends QueryNode ? IsNever> extends true ? never - : ArrayQueryNode> + : IsNever extends true + ? ArrayQueryNode> + : Obj.IsAllOptional extends true + ? ArrayQueryNode> & + ((input: TLastInput) => ArrayQueryNode>) + : (input: TLastInput) => ArrayQueryNode> : never : E extends object ? E extends Array ? never : TIsLastNullable extends true + ? IsNever extends true + ? ( + definition: ObjectSelector, + ) => NullableQueryNode>> + : Obj.IsAllOptional extends true + ? { + ( + input: TLastInput, + definition: ObjectSelector, + ): NullableQueryNode>>; + ( + definition: ObjectSelector, + ): NullableQueryNode>>; + } + : ( + input: TLastInput, + definition: ObjectSelector, + ) => NullableQueryNode>> + : IsNever extends true ? ( definition: ObjectSelector, - ) => NullableQueryNode>> + ) => ArrayQueryNode> + : Obj.IsAllOptional extends true + ? { + ( + input: TLastInput, + definition: ObjectSelector, + ): ArrayQueryNode>; + (definition: ObjectSelector): ArrayQueryNode< + K, + ObjectQueryNode + >; + } : ( + input: TLastInput, definition: ObjectSelector, ) => ArrayQueryNode> : never : [V] extends [object] ? TIsLastNullable extends true + ? IsNever extends true + ? ( + definition: ObjectSelector, + ) => NullableQueryNode> + : Obj.IsAllOptional extends true + ? { + ( + input: TLastInput, + definition: ObjectSelector, + ): NullableQueryNode>; + ( + definition: ObjectSelector, + ): NullableQueryNode>; + } + : ( + input: TLastInput, + definition: ObjectSelector, + ) => NullableQueryNode> + : IsNever extends true ? ( definition: ObjectSelector, - ) => NullableQueryNode> + ) => ObjectQueryNode + : Obj.IsAllOptional extends true + ? { + ( + input: TLastInput, + definition: ObjectSelector, + ): ObjectQueryNode; + (definition: ObjectSelector): ObjectQueryNode< + K, + R + >; + } : ( + input: TLastInput, definition: ObjectSelector, ) => ObjectQueryNode : [V] extends [string] - ? StringQueryNode + ? TIsLastNullable extends true + ? StringQueryNode + : IsNever extends true + ? StringQueryNode + : Obj.IsAllOptional extends true + ? StringQueryNode & ((input: TLastInput) => StringQueryNode) + : (input: TLastInput) => StringQueryNode : [V] extends [number] - ? NumberQueryNode + ? TIsLastNullable extends true + ? NumberQueryNode + : IsNever extends true + ? NumberQueryNode + : Obj.IsAllOptional extends true + ? NumberQueryNode & ((input: TLastInput) => NumberQueryNode) + : (input: TLastInput) => NumberQueryNode : [V] extends [boolean] - ? BooleanQueryNode + ? TIsLastNullable extends true + ? BooleanQueryNode + : IsNever extends true + ? BooleanQueryNode + : Obj.IsAllOptional extends true + ? BooleanQueryNode & ((input: TLastInput) => BooleanQueryNode) + : (input: TLastInput) => BooleanQueryNode : never; diff --git a/src/types/common.ts b/src/types/common.ts index 0f82d34..38bb9b2 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -169,6 +169,11 @@ export type RequiredFieldsCount = Obj.Length<{ [K in keyof I as K extends `${string}?` ? never : I[K] extends `${string}!` ? K : never]: void; }>; +/** + * Get the count of optional keys from an object. + */ +export type OptionalKeyCount = TuplifyLiteralStringUnion<_OptionalKeyOf>['length']; + /** * Merge two objects together. Optional keys are not considered. */ @@ -231,6 +236,8 @@ export namespace Obj { export type IfEmpty = IsEmpty extends true ? T : F; export type IfNotEmpty = IsEmpty extends true ? F : T; + + export type IsAllOptional = Length extends OptionalKeyCount ? true : false; } /******************** diff --git a/src/types/graphql-types.ts b/src/types/graphql-types.ts index b1501b3..bfdffbb 100644 --- a/src/types/graphql-types.ts +++ b/src/types/graphql-types.ts @@ -38,10 +38,12 @@ export interface BaseEnvironment { Boolean: boolean; } -export type TypeRepresentation = Record | string; -export type TypeCollection = Record; -export type FunctionRepresentation = ['=>', string] | [Record, '=>', string]; -export type FunctionCollection = Record; +export type ObjectDefinition = Record, string]>; +export type ScalarDefinition = string; +export type TypeDefinition = ObjectDefinition | ScalarDefinition | GraphQLEnum; +export type TypeCollection = Record; +export type OperationDefinition = ['=>', string] | [Record, '=>', string]; +export type OperationCollection = Record; export type WrapByVariant = TVariant extends 'NULLABLE-LIST-NULLABLE' ? Array | null diff --git a/src/types/parser.ts b/src/types/parser.ts index c46299d..631c9b1 100644 --- a/src/types/parser.ts +++ b/src/types/parser.ts @@ -1,15 +1,23 @@ -import type { Merge, StringKeyOf } from './common'; -import type { GraphQLEnum, GraphQLList, GraphQLNonNull } from './graphql-types'; +import type { Merge, SimpleMerge, StringKeyOf } from './common'; +import type { + GraphQLEnum, + GraphQLList, + GraphQLNonNull, + ObjectDefinition, + ScalarDefinition, +} from './graphql-types'; type TryResolve< T extends StringKeyOf<$>, $, TOptions extends { treatNullableTypeAsOptional?: boolean }, -> = $[T] extends string | Record - ? _Parse<$[T], $, TOptions> - : $[T] extends GraphQLEnum - ? S - : $[T]; +> = $[T] extends infer TDef + ? TDef extends ObjectDefinition | ScalarDefinition + ? _Parse + : TDef extends GraphQLEnum + ? S + : TDef + : never; export type ParseDef< TDef, @@ -33,11 +41,15 @@ export type ParseDef< : never : never; -type _Parse< - TDef, - $, - TOptions extends { treatNullableTypeAsOptional?: boolean }, -> = TDef extends Record +type _Parse = TDef extends [ + infer TInput, + infer TOutput, +] + ? [ + _Parse>, + _Parse, + ] + : TDef extends ObjectDefinition ? TOptions['treatNullableTypeAsOptional'] extends true ? Merge< { diff --git a/src/types/query-node.ts b/src/types/query-node.ts index 9a12ea5..638b0f9 100644 --- a/src/types/query-node.ts +++ b/src/types/query-node.ts @@ -6,6 +6,7 @@ export type QueryNode = export interface StringQueryNode { key: K; + args: Record; children: null; availableValues: TAvailableValues; __type: 'string'; @@ -13,12 +14,14 @@ export interface StringQueryNode { key: K; + args: Record; children: null; __type: 'number'; } export interface BooleanQueryNode { key: K; + args: Record; children: null; __type: 'boolean'; } @@ -27,12 +30,14 @@ export type PrimitiveQueryNode = StringQueryNode | NumberQueryNode | BooleanQuer export interface ArrayQueryNode { key: K; + args: Record; children: C; __type: 'array'; } export interface ObjectQueryNode { key: K; + args: Record; children: C; __type: 'object'; } diff --git a/test/e2e.spec.ts b/test/e2e.spec.ts index 9e2707d..627facc 100644 --- a/test/e2e.spec.ts +++ b/test/e2e.spec.ts @@ -8,88 +8,196 @@ import { trimIndent } from '@/utils'; describe('client', () => { const { mutation, query } = createClient('https://graphqlzero.almansi.me/api').withSchema({ - PageLimitPair: { - page: 'Int', - limit: 'Int', - }, - PaginationLinks: { - first: 'PageLimitPair', - prev: 'PageLimitPair', - next: 'PageLimitPair', - last: 'PageLimitPair', + Query: { + _: ['=>', 'Int'], + albums: [{ options: 'PageQueryOptions' }, '=>', 'AlbumsPage!'], + album: [{ id: 'ID!' }, '=>', 'Album'], + comments: [{ options: 'PageQueryOptions' }, '=>', 'CommentsPage!'], + comment: [{ id: 'ID!' }, '=>', 'Comment'], + photos: [{ options: 'PageQueryOptions' }, '=>', 'PhotosPage!'], + photo: [{ id: 'ID!' }, '=>', 'Photo'], + posts: [{ options: 'PageQueryOptions' }, '=>', 'PostsPage!'], + post: [{ id: 'ID!' }, '=>', 'Post'], + todos: [{ options: 'PageQueryOptions' }, '=>', 'TodosPage!'], + todo: [{ id: 'ID!' }, '=>', 'Todo'], + users: [{ options: 'PageQueryOptions' }, '=>', 'UsersPage!'], + user: [{ id: 'ID!' }, '=>', 'User'], }, - PageMetaData: { - totalCount: 'Int', + + Mutation: { + _: ['=>', 'Int'], + createAlbum: [{ input: 'CreateAlbumInput!' }, '=>', 'Album'], + updateAlbum: [{ id: 'ID!', input: 'UpdateAlbumInput!' }, '=>', 'Album'], + deleteAlbum: [{ id: 'ID!' }, '=>', 'Boolean!'], + createComment: [{ input: 'CreateCommentInput!' }, '=>', 'Comment'], + updateComment: [{ id: 'ID!', input: 'UpdateCommentInput!' }, '=>', 'Comment'], + deleteComment: [{ id: 'ID!' }, '=>', 'Boolean!'], + createPhoto: [{ input: 'CreatePhotoInput!' }, '=>', 'Photo'], + updatePhoto: [{ id: 'ID!', input: 'UpdatePhotoInput!' }, '=>', 'Photo'], + deletePhoto: [{ id: 'ID!' }, '=>', 'Boolean!'], + createPost: [{ input: 'CreatePostInput!' }, '=>', 'Post'], + updatePost: [{ id: 'ID!', input: 'UpdatePostInput!' }, '=>', 'Post'], + deletePost: [{ id: 'ID!' }, '=>', 'Boolean!'], + createTodo: [{ input: 'CreateTodoInput!' }, '=>', 'Todo'], + updateTodo: [{ id: 'ID!', input: 'UpdateTodoInput!' }, '=>', 'Todo'], + deleteTodo: [{ id: 'ID!' }, '=>', 'Boolean!'], + createUser: [{ input: 'CreateUserInput!' }, '=>', 'User'], + updateUser: [{ id: 'ID!', input: 'UpdateUserInput!' }, '=>', 'User'], + deleteUser: [{ id: 'ID!' }, '=>', 'Boolean!'], }, - OperatorKindEnum: enumOf('GTE', 'LTE', 'NE', 'LIKE'), + PageQueryOptions: { + paginate: 'PaginateOptions', + slice: 'SliceOptions', + sort: '[SortOptions]', + operator: '[OperatorOptions]', + search: 'SearchOptions', + }, PaginateOptions: { - 'page?': 'Int!', - 'limit?': 'Int!', + page: 'Int', + limit: 'Int', }, SliceOptions: { - 'start?': 'Int!', - 'end?': 'Int!', - 'limit?': 'Int!', + start: 'Int', + end: 'Int', + limit: 'Int', }, SortOptions: { - 'field?': 'String!', - 'order?': 'String!', + field: 'String', + order: 'SortOrderEnum', }, + SortOrderEnum: enumOf('ASC', 'DESC'), OperatorOptions: { - 'kind?': 'OperatorKindEnum!', - 'field?': 'String!', - 'value?': 'String!', + kind: 'OperatorKindEnum', + field: 'String', + value: 'String', }, + OperatorKindEnum: enumOf('GTE', 'LTE', 'NE', 'LIKE'), SearchOptions: { - 'q?': 'String!', + q: 'String', }, - PageQueryOptions: { - 'paginate?': 'PaginateOptions!', - 'slice?': 'SliceOptions!', - 'sort?': 'SortOptions!', - 'operator?': 'OperatorOptions!', - 'search?': 'SearchOptions!', + + PaginationLinks: { + first: 'PageLimitPair', + prev: 'PageLimitPair', + next: 'PageLimitPair', + last: 'PageLimitPair', + }, + PageLimitPair: { + page: 'Int', + limit: 'Int', + }, + PageMetadata: { + totalCount: 'Int!', }, - Geo: { - lat: 'Float', - lng: 'Float', + Album: { + id: 'ID', + title: 'String', + user: 'User', + photos: [{ options: 'PageQueryOptions' }, 'PhotosPage!'], }, - Address: { - street: 'String', - suite: 'String', - city: 'String', - zipcode: 'String', - geo: 'Geo', + AlbumsPage: { + data: '[Album!]!', + links: 'PaginationLinks', + meta: 'PageMetadata!', }, - Company: { - name: 'String', - catchPhrase: 'String', - bs: 'String', + CreateAlbumInput: { + title: 'String!', + userId: 'ID!', + }, + UpdateAlbumInput: { + title: 'String', + userId: 'ID', }, + Comment: { id: 'ID', name: 'String', email: 'String', body: 'String', + post: 'Post', }, CommentsPage: { - data: '[Comment!]', + data: '[Comment!]!', + links: 'PaginationLinks', + meta: 'PageMetadata', + }, + CreateCommentInput: { + name: 'String!', + email: 'String!', + body: 'String!', + }, + UpdateCommentInput: { + name: 'String', + email: 'String', + body: 'String', + }, + + Photo: { + id: 'ID', + title: 'String', + url: 'String', + thumbnailUrl: 'String', + album: 'Album', + }, + PhotosPage: { + data: '[Photo!]!', links: 'PaginationLinks', - meta: 'PageMetaData', + meta: 'PageMetadata!', + }, + CreatePhotoInput: { + title: 'String!', + url: 'String!', + thumbnailUrl: 'String!', }, + UpdatePhotoInput: { + title: 'String', + url: 'String', + thumbnailUrl: 'String', + }, + Post: { id: 'ID', title: 'String', body: 'String', - comments: 'CommentsPage', + user: 'User', + comments: [{ options: 'PageQueryOptions' }, 'CommentsPage'], }, PostsPage: { - data: '[Post!]', + data: '[Post!]!', links: 'PaginationLinks', - meta: 'PageMetaData', + meta: 'PageMetadata!', + }, + CreatePostInput: { + title: 'String!', + body: 'String!', }, + UpdatePostInput: { + title: 'String', + body: 'String', + }, + + Todo: { + id: 'ID', + title: 'String', + completed: 'Boolean', + user: 'User', + }, + TodosPage: { + data: '[Todo!]!', + links: 'PaginationLinks', + meta: 'PageMetadata!', + }, + CreateTodoInput: { + title: 'String!', + completed: 'Boolean!', + }, + UpdateTodoInput: { + title: 'String', + completed: 'Boolean', + }, + User: { id: 'ID', name: 'String', @@ -99,30 +207,64 @@ describe('client', () => { phone: 'String', website: 'String', company: 'Company', - posts: 'PostsPage', + posts: [{ options: 'PageQueryOptions' }, 'PostsPage'], + albums: [{ options: 'PageQueryOptions' }, 'AlbumsPage'], + todos: [{ options: 'PageQueryOptions' }, 'TodosPage'], }, - - CreatePostInput: { - title: 'String!', - body: 'String!', + UsersPage: { + data: '[User!]!', + links: 'PaginationLinks', + meta: 'PageMetadata!', }, - UpdatePostInput: { - 'title?': 'String!', - 'body?': 'String!', + Address: { + street: 'String', + suite: 'String', + city: 'String', + zipcode: 'String', + geo: 'Geo', }, - - Query: { - _: ['=>', 'Int'], - post: [{ id: 'ID!' }, '=>', 'Post'], - user: [{ id: 'ID!' }, '=>', 'User'], - posts: [{ 'options?': 'PageQueryOptions!' }, '=>', 'PostsPage'], + AddressInput: { + street: 'String', + suite: 'String', + city: 'String', + zipcode: 'String', + geo: 'GeoInput', }, - - Mutation: { - _: ['=>', 'Int'], - createPost: [{ input: 'CreatePostInput!' }, '=>', 'Post'], - updatePost: [{ id: 'ID!', input: 'UpdatePostInput!' }, '=>', 'Post'], - deletePost: [{ id: 'ID!' }, '=>', 'Boolean'], + Geo: { + lat: 'Float!', + lng: 'Float!', + }, + GeoInput: { + lat: 'Float', + lng: 'Float', + }, + Company: { + name: 'String', + catchPhrase: 'String', + bs: 'String', + }, + CompanyInput: { + name: 'String', + catchPhrase: 'String', + bs: 'String', + }, + CreateUserInput: { + name: 'String!', + username: 'String!', + email: 'String!', + address: 'AddressInput', + phone: 'String', + website: 'String', + company: 'CompanyInput', + }, + UpdateUserInput: { + name: 'String', + username: 'String', + email: 'String', + address: 'AddressInput', + phone: 'String', + website: 'String', + company: 'CompanyInput', }, }); @@ -285,4 +427,92 @@ describe('client', () => { const deleted = await mutation('deletePost').byId('101'); expect(deleted).toBe(true); }); + + it('should get albums with photos by page', async () => { + const albums = await query('albums') + .select((albums) => [ + albums.data((album) => [ + album.id, + album.title, + album.user((user) => [user.id, user.username, user.email]), + album.photos( + { + options: { + paginate: { page: 2, limit: 3 }, + sort: [{ field: 'title', order: 'ASC' }], + }, + }, + (photos) => [ + photos.data((photo) => [photo.id, photo.title]), + photos.meta((meta) => [meta.totalCount]), + ], + ), + ]), + albums.meta((meta) => [meta.totalCount]), + ]) + .byOptions({ paginate: { page: 3, limit: 2 } }); + expect(albums).toEqual({ + data: [ + { + id: '5', + title: 'eaque aut omnis a', + user: { + id: '1', + username: 'Bret', + email: 'Sincere@april.biz', + }, + photos: { + data: [ + { + id: '244', + title: 'aut doloribus quia unde quia', + }, + { + id: '204', + title: 'beatae est vel tenetur', + }, + { + id: '207', + title: 'culpa qui quos reiciendis aut nostrum et id temporibus', + }, + ], + meta: { + totalCount: 50, + }, + }, + }, + { + id: '6', + title: 'natus impedit quibusdam illo est', + user: { + id: '1', + username: 'Bret', + email: 'Sincere@april.biz', + }, + photos: { + data: [ + { + id: '294', + title: 'consequuntur qui et culpa eveniet porro quis', + }, + { + id: '275', + title: 'consequuntur quo fugit non', + }, + { + id: '259', + title: 'delectus molestias aut sint fugiat laudantium sequi praesentium', + }, + ], + meta: { + totalCount: 50, + }, + }, + }, + ], + meta: { + totalCount: 100, + }, + }); + }); });