Skip to content
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
2 changes: 1 addition & 1 deletion packages/bindx-client/src/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -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'
21 changes: 21 additions & 0 deletions packages/bindx-client/src/graphql/querySpecToGraphQl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/bindx-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 0 additions & 1 deletion packages/bindx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 2 additions & 6 deletions packages/bindx/src/adapter/ContemberAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildDeleteArgs,
buildMutationSelection,
buildNodeSelectionFromMutationData,
buildCountSelection,
mutationFragments,
unwrapPaginateResult,
} from '@contember/bindx-client'
Expand All @@ -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
Expand Down Expand Up @@ -121,11 +121,7 @@ export class ContemberAdapter implements BackendAdapter {

private buildCountQuery(query: CountQuery): ContentQuery<unknown> {
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',
Expand Down
81 changes: 81 additions & 0 deletions tests/bindx-client/countQuerySerialization.test.ts
Original file line number Diff line number Diff line change
@@ -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<Entity> { }` — 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>('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<Entity> 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*}/)
})
})