diff --git a/packages/bindx-react/src/hooks/useEntity.ts b/packages/bindx-react/src/hooks/useEntity.ts index e750dcb..76ec05f 100644 --- a/packages/bindx-react/src/hooks/useEntity.ts +++ b/packages/bindx-react/src/hooks/useEntity.ts @@ -324,8 +324,8 @@ export function useEntity( // --- EntityHandle --- const rawHandle = useMemo( - () => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry>), - [id, entityType, store, dispatcher, schemaRegistry, snapshot], + () => EntityHandle.createRaw(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry>, undefined, selectionMeta), + [id, entityType, store, dispatcher, schemaRegistry, snapshot, selectionMeta], ) const handle = useMemo( diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 11bdfe8..d4cb345 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -341,6 +341,8 @@ export function useEntityList( store, dispatcher, schemaRegistry as SchemaRegistry>, + undefined, + selectionMeta, ) as unknown as EntityAccessor }) @@ -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, b: UseEntityListResult): boolean => { diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index d5db13a..d6f381b 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -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. @@ -474,12 +474,14 @@ export class EntityHandle extends Enti get fields(): EntityFieldsAccessor { return new Proxy({} as EntityFieldsAccessor, { 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) @@ -495,8 +497,10 @@ export class EntityHandle 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_`) for params-bearing relations. + return this.hasMany(fieldName, fieldMeta?.alias, nestedSelection) } // Unknown field type - fallback to FieldHandle @@ -505,6 +509,30 @@ export class EntityHandle 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_`) 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 is not assignable to EntityRef. * This is a phantom property that only exists in the type system. diff --git a/tests/react/hooks/useEntity/nestedHasManyOrderBy.test.tsx b/tests/react/hooks/useEntity/nestedHasManyOrderBy.test.tsx new file mode 100644 index 0000000..75b2eb3 --- /dev/null +++ b/tests/react/hooks/useEntity/nestedHasManyOrderBy.test.tsx @@ -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({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + tags: hasMany('Tag'), + }, + }, + Tag: { + fields: { + id: scalar(), + name: scalar(), + order: scalar(), + }, + }, + }, +}) + +const entityDefs = { + Article: entityDef
('Article'), + Tag: entityDef('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
Loading...
+ return ( +
+ {article.title.value} + {article.tags.items.length} + {article.tags.items.map(t => t.name.value).join(',')} +
+ ) + } + + const { container } = render( + + + , + ) + + 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
Loading...
+ return {article.tags.items.length} + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'tag-count')).not.toBeNull() + }) + + expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2') + }) +}) diff --git a/tests/react/hooks/useEntityList/nestedHasManyOrderBy.test.tsx b/tests/react/hooks/useEntityList/nestedHasManyOrderBy.test.tsx new file mode 100644 index 0000000..1812c3c --- /dev/null +++ b/tests/react/hooks/useEntityList/nestedHasManyOrderBy.test.tsx @@ -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({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + tags: hasMany('Tag'), + }, + }, + Tag: { + fields: { + id: scalar(), + name: scalar(), + order: scalar(), + }, + }, + }, +}) + +const entityDefs = { + Article: entityDef
('Article'), + Tag: entityDef('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
Loading...
+ const article = list.items[0] + return ( +
+ {article?.title.value} + {article ? article.tags.items.length : -1} +
+ ) + } + + const { container } = render( + + + , + ) + + 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
Loading...
+ const article = list.items[0] + return {article ? article.tags.items.length : -1} + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'tag-count')).not.toBeNull() + }) + + expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2') + }) +})