diff --git a/packages/bindx-client/src/graphql/index.ts b/packages/bindx-client/src/graphql/index.ts index ff746e6b..712252db 100644 --- a/packages/bindx-client/src/graphql/index.ts +++ b/packages/bindx-client/src/graphql/index.ts @@ -1,3 +1,3 @@ -export { querySpecToGraphQl, unwrapPaginateResult, type QuerySpecContext } from './querySpecToGraphQl.js' +export { querySpecToGraphQl, buildCountSelection, unwrapPaginateResult, type QuerySpecContext } from './querySpecToGraphQl.js' export { buildTypedArgs, buildListArgs, buildGetArgs, buildCreateArgs, buildUpdateArgs, buildUpsertArgs, buildDeleteArgs } from './buildTypedArgs.js' export { mutationFragments, buildMutationSelection, buildNodeSelectionFromMutationData } from './mutationFragments.js' diff --git a/packages/bindx-client/src/graphql/querySpecToGraphQl.ts b/packages/bindx-client/src/graphql/querySpecToGraphQl.ts index 2585743d..722be2be 100644 --- a/packages/bindx-client/src/graphql/querySpecToGraphQl.ts +++ b/packages/bindx-client/src/graphql/querySpecToGraphQl.ts @@ -129,6 +129,27 @@ function buildPaginateField( return new GraphQlField(alias, paginateFieldName, args, paginateSelection) } +/** + * Builds the selection set for a standalone count query. + * + * Generates: + * ```graphql + * pageInfo { totalCount } + * ``` + * + * Must live here (and not in the adapter package) so the `GraphQlField` instances + * are created by the same `@contember/graphql-builder` copy that the query printer + * uses for `instanceof` checks — otherwise the selection set is silently dropped + * when a consumer resolves a divergent graphql-builder version for the two packages. + */ +export function buildCountSelection(): GraphQlSelectionSet { + return [ + new GraphQlField(null, 'pageInfo', {}, [ + new GraphQlField(null, 'totalCount'), + ]), + ] +} + /** * Transform function for unwrapping paginateRelation Connection format to flat arrays. * diff --git a/packages/bindx-client/src/index.ts b/packages/bindx-client/src/index.ts index e1084eae..66236bee 100644 --- a/packages/bindx-client/src/index.ts +++ b/packages/bindx-client/src/index.ts @@ -129,6 +129,6 @@ export { MutationFailedError } from './operations/index.js' export { ContentClient, type ContentClientOptions } from './client/index.js' // GraphQL internals (for advanced use) -export { querySpecToGraphQl, unwrapPaginateResult, type QuerySpecContext } from './graphql/index.js' +export { querySpecToGraphQl, buildCountSelection, unwrapPaginateResult, type QuerySpecContext } from './graphql/index.js' export { buildTypedArgs, buildListArgs, buildGetArgs, buildCreateArgs, buildUpdateArgs, buildUpsertArgs, buildDeleteArgs } from './graphql/index.js' export { mutationFragments, buildMutationSelection, buildNodeSelectionFromMutationData } from './graphql/index.js' diff --git a/packages/bindx/package.json b/packages/bindx/package.json index e1a1adce..40342b76 100644 --- a/packages/bindx/package.json +++ b/packages/bindx/package.json @@ -18,7 +18,6 @@ "license": "MIT", "dependencies": { "@contember/bindx-client": "workspace:*", - "@contember/graphql-builder": "^2.1.0-beta.1", "@contember/graphql-client": "^2.1.0-beta.1" }, "repository": { diff --git a/packages/bindx/src/adapter/ContemberAdapter.ts b/packages/bindx/src/adapter/ContemberAdapter.ts index d7e3dd91..a24bf172 100644 --- a/packages/bindx/src/adapter/ContemberAdapter.ts +++ b/packages/bindx/src/adapter/ContemberAdapter.ts @@ -12,6 +12,7 @@ import { buildDeleteArgs, buildMutationSelection, buildNodeSelectionFromMutationData, + buildCountSelection, mutationFragments, unwrapPaginateResult, } from '@contember/bindx-client' @@ -20,7 +21,6 @@ import type { QuerySpec, QueryFieldSpec } from '../selection/buildQuery.js' import type { BackendAdapter, Query, QueryResult, QueryOptions, GetQuery, ListQuery, CountQuery, PersistResult, CreateResult, DeleteResult } from './types.js' import type { ContemberMutationResult } from '../errors/pathMapper.js' import type { SchemaRegistry } from '../schema/SchemaRegistry.js' -import { GraphQlField } from '@contember/graphql-builder' /** * Options for ContemberAdapter @@ -121,11 +121,7 @@ export class ContemberAdapter implements BackendAdapter { private buildCountQuery(query: CountQuery): ContentQuery { const args = buildListArgs(query.entityType, { filter: query.filter }, 'paginate') - const selectionSet = [ - new GraphQlField(null, 'pageInfo', {}, [ - new GraphQlField(null, 'totalCount'), - ]), - ] + const selectionSet = buildCountSelection() return new ContentOperation( 'query', diff --git a/tests/bindx-client/countQuerySerialization.test.ts b/tests/bindx-client/countQuerySerialization.test.ts new file mode 100644 index 00000000..59a4e296 --- /dev/null +++ b/tests/bindx-client/countQuerySerialization.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect } from 'bun:test' +import { ContentClient, ContentOperation, buildCountSelection, qb, entityDef } from '@contember/bindx-client' + +// ============================================================================ +// Regression: count query must serialize with a non-empty selection set +// +// A standalone count query is the only selection set that used to be built +// outside @contember/bindx-client (directly in ContemberAdapter, against a +// separate @contember/graphql-builder copy). When a consumer resolved a +// divergent graphql-builder version for the two packages, the query printer's +// `node instanceof GraphQlField` check failed and silently dropped the fields, +// emitting `paginate { }` — invalid GraphQL ("Expected Name, found }"). +// +// Building the selection set inside bindx-client (buildCountSelection) keeps +// every GraphQlField on the same copy the printer uses. +// ============================================================================ + +interface Article { + id: string + title: string +} + +const schema = { + Article: entityDef
('Article'), +} as const + +function createCapturingClient(response: unknown): { client: ContentClient; getLastQuery: () => string } { + let lastQuery = '' + const client = new ContentClient({ + execute: async (query: string) => { + lastQuery = query + return response as never + }, + }) + return { client, getLastQuery: () => lastQuery } +} + +describe('buildCountSelection', () => { + test('produces a pageInfo { totalCount } selection set', () => { + const selection = buildCountSelection() + expect(selection).toHaveLength(1) + const pageInfo = selection[0] as { name: string; selectionSet?: { name: string }[] } + expect(pageInfo.name).toBe('pageInfo') + expect(pageInfo.selectionSet?.map(f => f.name)).toEqual(['totalCount']) + }) +}) + +describe('count query serialization', () => { + test('serializes paginate with a non-empty body', async () => { + const { client, getLastQuery } = createCapturingClient({ value: { pageInfo: { totalCount: 7 } } }) + + // parse output is irrelevant here — the test asserts on the serialized query. + const countQuery = new ContentOperation( + 'query', + 'paginateArticle', + {}, + buildCountSelection(), + (): number => 0, + ) + + await client.query(countQuery) + + const printed = getLastQuery() + expect(printed).toContain('paginateArticle') + expect(printed).toContain('pageInfo') + expect(printed).toContain('totalCount') + // The bug rendered an empty body: `paginateArticle { }` + expect(printed).not.toMatch(/paginateArticle\s*{\s*}/) + }) + + test('qb.count serializes identically (same selection shape)', async () => { + const { client, getLastQuery } = createCapturingClient({ value: { pageInfo: { totalCount: 3 } } }) + + await client.query(qb.count(schema.Article, {})) + + const printed = getLastQuery() + expect(printed).toContain('pageInfo') + expect(printed).toContain('totalCount') + expect(printed).not.toMatch(/paginateArticle\s*{\s*}/) + }) +})