Skip to content
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 { buildTypedArgs, buildListArgs, buildGetArgs, buildCreateArgs, buildUpdateArgs, buildUpsertArgs, buildDeleteArgs } from './buildTypedArgs.js'
export { mutationFragments, buildMutationSelection } from './mutationFragments.js'
export { mutationFragments, buildMutationSelection, buildNodeSelectionFromMutationData } from './mutationFragments.js'
112 changes: 112 additions & 0 deletions packages/bindx-client/src/graphql/mutationFragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,115 @@ export function buildMutationSelection(
}
return items
}

/**
* Builds a GraphQL node selection set from mutation data.
* Recursively traverses the mutation data structure to find inline create
* operations and builds a selection requesting `id` for each nested relation.
*
* Example: for mutation data `{ approval: { create: { rounds: [{ create: { reviews: [{ create: {...} }] } }] } } }`
* produces selection: `{ id, approval { id, rounds { id, reviews { id } } } }`
*/
export function buildNodeSelectionFromMutationData(
data: Record<string, unknown>,
): import('@contember/graphql-builder').GraphQlSelectionSet {
const fields: import('@contember/graphql-builder').GraphQlSelectionSet = [
new GraphQlField(null, 'id'),
]

for (const [fieldName, value] of Object.entries(data)) {
if (value === null || value === undefined) continue

if (Array.isArray(value)) {
// HasMany operations: array of { create: {...}, connect: {...}, ... }
const nestedSelection = buildSelectionFromHasManyOps(value)
if (nestedSelection) {
fields.push(new GraphQlField(null, fieldName, {}, nestedSelection))
}
} else if (typeof value === 'object') {
// HasOne operation: { create: {...} }, { update: {...} }, { connect: {...} }
const nestedSelection = buildSelectionFromHasOneOp(value as Record<string, unknown>)
if (nestedSelection) {
fields.push(new GraphQlField(null, fieldName, {}, nestedSelection))
}
}
}

return fields
}

/**
* Builds selection from a hasOne operation object.
* Returns selection if the operation contains a create or update with nested creates.
*/
function buildSelectionFromHasOneOp(
op: Record<string, unknown>,
): import('@contember/graphql-builder').GraphQlSelectionSet | undefined {
if ('create' in op && typeof op['create'] === 'object' && op['create'] !== null) {
return buildNodeSelectionFromMutationData(op['create'] as Record<string, unknown>)
}
if ('update' in op && typeof op['update'] === 'object' && op['update'] !== null) {
return buildNodeSelectionFromMutationData(op['update'] as Record<string, unknown>)
}
return undefined
}

/**
* Builds selection from hasMany operations array.
* Merges selections from all create/update operations to produce a unified selection.
*/
function buildSelectionFromHasManyOps(
ops: unknown[],
): import('@contember/graphql-builder').GraphQlSelectionSet | undefined {
let hasNested = false

// Collect all nested field names from create/update operations
const nestedFields = new Map<string, Record<string, unknown>>()

for (const item of ops) {
if (typeof item !== 'object' || item === null) continue
const op = item as Record<string, unknown>

let nestedData: Record<string, unknown> | null = null
if ('create' in op && typeof op['create'] === 'object' && op['create'] !== null) {
nestedData = op['create'] as Record<string, unknown>
hasNested = true
} else if ('update' in op && typeof op['update'] === 'object' && op['update'] !== null) {
const update = op['update'] as Record<string, unknown>
nestedData = ('data' in update ? update['data'] : update) as Record<string, unknown>
hasNested = true
}

if (nestedData) {
for (const [key, value] of Object.entries(nestedData)) {
if (value === null || value === undefined) continue
if (typeof value === 'object') {
nestedFields.set(key, value as Record<string, unknown>)
}
}
}
}

if (!hasNested) return undefined

// Build selection with id + any nested relation fields
const fields: import('@contember/graphql-builder').GraphQlSelectionSet = [
new GraphQlField(null, 'id'),
]

for (const [fieldName, value] of nestedFields) {
if (Array.isArray(value)) {
const nestedSelection = buildSelectionFromHasManyOps(value)
if (nestedSelection) {
fields.push(new GraphQlField(null, fieldName, {}, nestedSelection))
}
} else {
const nestedSelection = buildSelectionFromHasOneOp(value as Record<string, unknown>)
if (nestedSelection) {
fields.push(new GraphQlField(null, fieldName, {}, nestedSelection))
}
}
}

return fields
}
2 changes: 1 addition & 1 deletion packages/bindx-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,4 @@ export { ContentClient, type ContentClientOptions } from './client/index.js'
// GraphQL internals (for advanced use)
export { querySpecToGraphQl, unwrapPaginateResult, type QuerySpecContext } from './graphql/index.js'
export { buildTypedArgs, buildListArgs, buildGetArgs, buildCreateArgs, buildUpdateArgs, buildUpsertArgs, buildDeleteArgs } from './graphql/index.js'
export { mutationFragments, buildMutationSelection } from './graphql/index.js'
export { mutationFragments, buildMutationSelection, buildNodeSelectionFromMutationData } from './graphql/index.js'
1 change: 1 addition & 0 deletions packages/bindx-react/src/jsx/collectorProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ function createCollectorFieldRef(
$hasError: false,
$addError: () => {},
$clearErrors: () => {},
$create: () => '',
$connect: () => {},
$disconnect: () => {},
$isConnected: false,
Expand Down
16 changes: 11 additions & 5 deletions packages/bindx/src/adapter/ContemberAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildUpdateArgs,
buildDeleteArgs,
buildMutationSelection,
buildNodeSelectionFromMutationData,
mutationFragments,
unwrapPaginateResult,
} from '@contember/bindx-client'
Expand Down Expand Up @@ -119,19 +120,24 @@ export class ContemberAdapter implements BackendAdapter {
changes: Record<string, unknown>,
): Promise<PersistResult> {
const args = buildUpdateArgs(entityType, { by: { id }, data: changes })
const selectionSet = buildMutationSelection('update')
const nodeSelection = buildNodeSelectionFromMutationData(changes)
const selectionSet = buildMutationSelection('update', nodeSelection)

const mutation = new ContentOperation(
'mutation',
`update${entityType}`,
args,
selectionSet,
value => value as { ok: boolean; errorMessage: string | null; errors: unknown[]; validation: unknown },
value => value as { ok: boolean; errorMessage: string | null; errors: unknown[]; validation: unknown; node: unknown },
)

try {
const result = await this.contentClient.mutate(mutation)
return { ok: true }
const typedResult = result as { node: Record<string, unknown> | null }
return {
ok: true,
data: typedResult.node as Record<string, unknown> | undefined,
}
} catch (e) {
if (e instanceof Error && 'result' in e) {
const mutResult = (e as { result: Record<string, unknown> }).result
Expand All @@ -150,8 +156,8 @@ export class ContemberAdapter implements BackendAdapter {
data: Record<string, unknown>,
): Promise<CreateResult> {
const args = buildCreateArgs(entityType, { data })
// Select id on created node
const nodeSelection = [new GraphQlField(null, 'id')]
// Select id on created node + nested created entity IDs
const nodeSelection = buildNodeSelectionFromMutationData(data)
const selectionSet = buildMutationSelection('create', nodeSelection)

const mutation = new ContentOperation(
Expand Down
4 changes: 4 additions & 0 deletions packages/bindx/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export type QueryResult = GetQueryResult | ListQueryResult
export interface PersistResult {
readonly ok: boolean
readonly errorMessage?: string
/** The entity node data after mutation (when ok is true). Contains nested entity IDs for inline creates. */
readonly data?: Record<string, unknown>
/** Detailed mutation result for error mapping (Contember-specific) */
readonly mutationResult?: ContemberMutationResult
}
Expand Down Expand Up @@ -128,6 +130,8 @@ export interface TransactionMutationResult {
readonly persistedId?: string
readonly errorMessage?: string
readonly mutationResult?: ContemberMutationResult
/** Results for entities created inline within this mutation's data. */
readonly nestedResults?: readonly TransactionMutationResult[]
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/bindx/src/core/ActionDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export class ActionDispatcher {
state: 'connected',
},
)
this.store.registerParentChild(action.entityType, action.entityId, action.targetType, action.targetId)
break

case 'DISCONNECT_RELATION':
Expand Down Expand Up @@ -293,6 +294,7 @@ export class ActionDispatcher {
action.itemId,
action.alias,
)
this.store.registerParentChild(action.entityType, action.entityId, action.targetType, action.itemId)
break

case 'ADD_TO_LIST': {
Expand All @@ -306,6 +308,7 @@ export class ActionDispatcher {
itemId,
action.alias,
)
this.store.registerParentChild(action.entityType, action.entityId, action.targetType, itemId)
break
}

Expand Down
8 changes: 6 additions & 2 deletions packages/bindx/src/core/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface ConnectRelationAction {
readonly entityId: string
readonly fieldName: string
readonly targetId: string
readonly targetType: string
}

/**
Expand Down Expand Up @@ -126,6 +127,7 @@ export interface ConnectToListAction {
readonly entityId: string
readonly fieldName: string
readonly itemId: string
readonly targetType: string
readonly alias?: string
}

Expand Down Expand Up @@ -355,8 +357,9 @@ export function connectRelation(
entityId: string,
fieldName: string,
targetId: string,
targetType: string,
): ConnectRelationAction {
return { type: 'CONNECT_RELATION', entityType, entityId, fieldName, targetId }
return { type: 'CONNECT_RELATION', entityType, entityId, fieldName, targetId, targetType }
}

/**
Expand Down Expand Up @@ -517,9 +520,10 @@ export function connectToList(
entityId: string,
fieldName: string,
itemId: string,
targetType: string,
alias?: string,
): ConnectToListAction {
return { type: 'CONNECT_TO_LIST', entityType, entityId, fieldName, itemId, alias }
return { type: 'CONNECT_TO_LIST', entityType, entityId, fieldName, itemId, targetType, alias }
}

/**
Expand Down
7 changes: 1 addition & 6 deletions packages/bindx/src/handles/HasManyListHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,8 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
connect(itemId: string): void {
this.assertNotDisposed()
this.dispatcher.dispatch(
connectToList(this.entityType, this.entityId, this.fieldName, itemId, this.alias),
connectToList(this.entityType, this.entityId, this.fieldName, itemId, this.itemType, this.alias),
)
// Register parent-child so that changes to the connected entity propagate to the parent
this.store.registerParentChild(this.entityType, this.entityId, this.itemType, itemId)
}

/**
Expand Down Expand Up @@ -350,9 +348,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
addToList(this.entityType, this.entityId, this.fieldName, this.itemType, tempId, this.alias),
)

// Register parent-child so that changes to the new entity propagate to the parent
this.store.registerParentChild(this.entityType, this.entityId, this.itemType, tempId)

return tempId
}

Expand Down
16 changes: 15 additions & 1 deletion packages/bindx/src/handles/HasOneHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,27 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
)
}

/**
* Creates a new entity of the target type and connects it to this relation.
* Returns the temp ID of the created entity.
* Accessible via proxy as `$create()`.
*/
create(data?: Partial<TEntity>): string {
this.assertNotDisposed()
const tempId = this.store.createEntity(this.targetType, data as Record<string, unknown>)
this.dispatcher.dispatch(
connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType),
)
return tempId
}

/**
* Connects the relation to an entity.
*/
connect(targetId: string): void {
this.assertNotDisposed()
this.dispatcher.dispatch(
connectRelation(this.entityType, this.entityId, this.fieldName, targetId),
connectRelation(this.entityType, this.entityId, this.fieldName, targetId, this.targetType),
)
}

Expand Down
1 change: 1 addition & 0 deletions packages/bindx/src/handles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export interface HasOneRefInterface<
readonly __entityType: TEntity
readonly __selected?: TSelected
readonly __brands?: Set<symbol>
$create(data?: Partial<TEntity>): string
$connect(id: string): void
$disconnect(): void
$delete(): void
Expand Down
Loading
Loading