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
4 changes: 2 additions & 2 deletions packages/bindx-react/src/hooks/useEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ export function useEntity(

// --- EntityHandle ---
const rawHandle = useMemo(
() => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>),
[id, entityType, store, dispatcher, schemaRegistry, snapshot],
() => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>, undefined, selectionMeta),
[id, entityType, store, dispatcher, schemaRegistry, snapshot, selectionMeta],
)

const handle = useMemo(
Expand Down
4 changes: 3 additions & 1 deletion packages/bindx-react/src/hooks/useEntityList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ export function useEntityList(
store,
dispatcher,
schemaRegistry as SchemaRegistry<Record<string, object>>,
undefined,
selectionMeta,
) as unknown as EntityAccessor<any>
})

Expand Down Expand Up @@ -368,7 +370,7 @@ export function useEntityList(
}

return result
}, [entityType, store, dispatcher, schemaRegistry, addItem, removeItem, moveItem])
}, [entityType, store, dispatcher, schemaRegistry, selectionMeta, addItem, removeItem, moveItem])

const isEqual = useCallback(
(a: UseEntityListResult<any>, b: UseEntityListResult<any>): boolean => {
Expand Down
38 changes: 33 additions & 5 deletions packages/bindx/src/handles/EntityHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type {
import { HasOneHandle } from './HasOneHandle.js'
import { HasManyListHandle } from './HasManyListHandle.js'
import { createHandleProxy } from './proxyFactory.js'
import type { SelectionMeta } from '../selection/types.js'
import type { SelectionMeta, SelectionFieldMeta } from '../selection/types.js'
import { UnfetchedFieldError } from '../errors/UnfetchedFieldError.js'

/** Minimal internal interface for cached relation handles that need reset/dispose.
Expand Down Expand Up @@ -474,12 +474,14 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
get fields(): EntityFieldsAccessor<T, TSelected> {
return new Proxy({} as EntityFieldsAccessor<T, TSelected>, {
get: (_, fieldName: string) => {
const fieldMeta = this.resolveSelectionField(fieldName)

// Selection validation
if (this.selection && !this.selection.fields.has(fieldName)) {
if (this.selection && !fieldMeta) {
throw new UnfetchedFieldError(this.entityType, this.entityId, [fieldName])
}

const nestedSelection = this.selection?.fields.get(fieldName)?.nested
const nestedSelection = fieldMeta?.nested

// Use schema to determine field type
const fieldDef = this.schema.getFieldDef(this.entityType, fieldName)
Expand All @@ -495,8 +497,10 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
}

if (fieldDef.type === 'hasMany') {
// Has-many relation - return HasManyListHandle
return this.hasMany(fieldName, undefined, nestedSelection)
// Has-many relation - return HasManyListHandle.
// Thread the selected alias so the handle reads data stored under the
// auto-generated alias (e.g. `tags_<hash>`) for params-bearing relations.
return this.hasMany(fieldName, fieldMeta?.alias, nestedSelection)
}

// Unknown field type - fallback to FieldHandle
Expand All @@ -505,6 +509,30 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
})
}

/**
* Resolves the selection metadata for an accessed property name.
*
* Direct lookup covers scalars, has-one, plain has-many, and explicit-alias access.
* A has-many selected with params (filter/orderBy/limit/offset) is keyed by an
* auto-generated alias (e.g. `tags_<hash>`) while consumers still read it by the
* real field name (`tags`). For that case fall back to matching on `fieldName` so
* the stored alias is recovered. The fallback is restricted to array relations:
* explicit scalar/has-one aliases are addressed by their chosen alias and must
* still report as unfetched when read by their original name.
*/
private resolveSelectionField(name: string): SelectionFieldMeta | undefined {
if (!this.selection) return undefined

const direct = this.selection.fields.get(name)
if (direct) return direct

for (const meta of this.selection.fields.values()) {
if (meta.isArray && meta.fieldName === name) return meta
}

return undefined
}

/**
* Type brand - ensures EntityRef<Author> is not assignable to EntityRef<Tag>.
* This is a phantom property that only exists in the type system.
Expand Down
129 changes: 129 additions & 0 deletions tests/react/hooks/useEntity/nestedHasManyOrderBy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import '../../../setup'
// Regression test for https://github.com/contember/bindx/issues/53 (nested hasMany with orderBy reads empty from a single-entity accessor)
import { afterEach, describe, expect, test } from 'bun:test'
import { cleanup, render, waitFor } from '@testing-library/react'
import React from 'react'
import { BindxProvider, MockAdapter, defineSchema, entityDef, hasMany, scalar, useEntity } from '@contember/bindx-react'

afterEach(() => {
cleanup()
})

interface Tag {
id: string
name: string
order: number
}

interface Article {
id: string
title: string
tags: Tag[]
}

interface TestSchema {
Article: Article
Tag: Tag
}

const schema = defineSchema<TestSchema>({
entities: {
Article: {
fields: {
id: scalar(),
title: scalar(),
tags: hasMany('Tag'),
},
},
Tag: {
fields: {
id: scalar(),
name: scalar(),
order: scalar(),
},
},
},
})

const entityDefs = {
Article: entityDef<Article>('Article'),
Tag: entityDef<Tag>('Tag'),
} as const

function createMockData() {
return {
Article: {
'article-1': {
id: 'article-1',
title: 'Test Article',
tags: [
{ id: 'tag-2', name: 'React', order: 1 },
{ id: 'tag-1', name: 'JavaScript', order: 0 },
],
},
},
}
}

function queryByTestId(container: Element, testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`)
}

describe('useEntity nested hasMany with orderBy', () => {
test('should expose nested hasMany items (ordered) when the relation is selected WITH orderBy', async () => {
const adapter = new MockAdapter(createMockData(), { delay: 0 })

function TestComponent(): React.ReactElement {
const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, a =>
a.id().title().tags({ orderBy: [{ order: 'asc' }] }, t => t.id().name()),
)
if (article.$status !== 'ready') return <div data-testid="loading">Loading...</div>
return (
<div>
<span data-testid="title">{article.title.value}</span>
<span data-testid="tag-count">{article.tags.items.length}</span>
<span data-testid="tag-names">{article.tags.items.map(t => t.name.value).join(',')}</span>
</div>
)
}

const { container } = render(
<BindxProvider adapter={adapter} schema={schema}>
<TestComponent />
</BindxProvider>,
)

await waitFor(() => {
expect(queryByTestId(container, 'title')).not.toBeNull()
})

expect(container.querySelector('[data-testid="title"]')?.textContent).toBe('Test Article')
expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
// orderBy must be honored on the read path: order asc -> JavaScript (0) before React (1)
expect(container.querySelector('[data-testid="tag-names"]')?.textContent).toBe('JavaScript,React')
})

test('control: nested hasMany WITHOUT orderBy exposes items', async () => {
const adapter = new MockAdapter(createMockData(), { delay: 0 })

function TestComponent(): React.ReactElement {
const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, a =>
a.id().title().tags(t => t.id().name()),
)
if (article.$status !== 'ready') return <div data-testid="loading">Loading...</div>
return <span data-testid="tag-count">{article.tags.items.length}</span>
}

const { container } = render(
<BindxProvider adapter={adapter} schema={schema}>
<TestComponent />
</BindxProvider>,
)

await waitFor(() => {
expect(queryByTestId(container, 'tag-count')).not.toBeNull()
})

expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
})
})
132 changes: 132 additions & 0 deletions tests/react/hooks/useEntityList/nestedHasManyOrderBy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import '../../../setup'
// Regression test for https://github.com/contember/bindx/issues/53 (nested hasMany with orderBy reads empty from a list-loaded accessor)
import { afterEach, describe, expect, test } from 'bun:test'
import { cleanup, render, waitFor } from '@testing-library/react'
import React from 'react'
import { BindxProvider, MockAdapter, defineSchema, entityDef, hasMany, scalar, useEntityList } from '@contember/bindx-react'

afterEach(() => {
cleanup()
})

interface Tag {
id: string
name: string
order: number
}

interface Article {
id: string
title: string
tags: Tag[]
}

interface TestSchema {
Article: Article
Tag: Tag
}

const schema = defineSchema<TestSchema>({
entities: {
Article: {
fields: {
id: scalar(),
title: scalar(),
tags: hasMany('Tag'),
},
},
Tag: {
fields: {
id: scalar(),
name: scalar(),
order: scalar(),
},
},
},
})

const entityDefs = {
Article: entityDef<Article>('Article'),
Tag: entityDef<Tag>('Tag'),
} as const

function createMockData() {
return {
Article: {
'article-1': {
id: 'article-1',
title: 'Test Article',
tags: [
{ id: 'tag-1', name: 'JavaScript', order: 0 },
{ id: 'tag-2', name: 'React', order: 1 },
],
},
},
}
}

function queryByTestId(container: Element, testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`)
}

// The nested hasMany `tags` is selected with an `orderBy` argument. The adapter returns the two tags
// (verifiable by logging the raw query result), but reading `list.items[0].tags.items` yields an empty
// array — the relation's scalar siblings (title) load fine, only the ordered hasMany comes back empty.
describe('useEntityList nested hasMany with orderBy', () => {
test('should expose nested hasMany items when the relation is selected WITH orderBy', async () => {
const adapter = new MockAdapter(createMockData(), { delay: 0 })

function TestComponent(): React.ReactElement {
const list = useEntityList(entityDefs.Article, { filter: { id: { eq: 'article-1' } } }, a =>
a.id().title().tags({ orderBy: [{ order: 'asc' }] }, t => t.id().name()),
)
if (list.$status !== 'ready') return <div data-testid="loading">Loading...</div>
const article = list.items[0]
return (
<div>
<span data-testid="title">{article?.title.value}</span>
<span data-testid="tag-count">{article ? article.tags.items.length : -1}</span>
</div>
)
}

const { container } = render(
<BindxProvider adapter={adapter} schema={schema}>
<TestComponent />
</BindxProvider>,
)

await waitFor(() => {
expect(queryByTestId(container, 'title')).not.toBeNull()
})

expect(container.querySelector('[data-testid="title"]')?.textContent).toBe('Test Article')
// Bug: reads '0'. The two ordered tags are dropped from the accessor.
expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
})

test('control: nested hasMany WITHOUT orderBy exposes items', async () => {
const adapter = new MockAdapter(createMockData(), { delay: 0 })

function TestComponent(): React.ReactElement {
const list = useEntityList(entityDefs.Article, { filter: { id: { eq: 'article-1' } } }, a =>
a.id().title().tags(t => t.id().name()),
)
if (list.$status !== 'ready') return <div data-testid="loading">Loading...</div>
const article = list.items[0]
return <span data-testid="tag-count">{article ? article.tags.items.length : -1}</span>
}

const { container } = render(
<BindxProvider adapter={adapter} schema={schema}>
<TestComponent />
</BindxProvider>,
)

await waitFor(() => {
expect(queryByTestId(container, 'tag-count')).not.toBeNull()
})

expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
})
})