Skip to content

Commit ad21250

Browse files
authored
Merge pull request #54 from contember/fix/nested-hasmany-orderby-empty-accessor
fix(bindx): resolve auto-generated has-many alias on accessor reads (#53)
2 parents 2cbb3ad + 1cad4db commit ad21250

5 files changed

Lines changed: 299 additions & 8 deletions

File tree

packages/bindx-react/src/hooks/useEntity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,8 @@ export function useEntity(
324324

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

331331
const handle = useMemo(

packages/bindx-react/src/hooks/useEntityList.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,8 @@ export function useEntityList(
341341
store,
342342
dispatcher,
343343
schemaRegistry as SchemaRegistry<Record<string, object>>,
344+
undefined,
345+
selectionMeta,
344346
) as unknown as EntityAccessor<any>
345347
})
346348

@@ -368,7 +370,7 @@ export function useEntityList(
368370
}
369371

370372
return result
371-
}, [entityType, store, dispatcher, schemaRegistry, addItem, removeItem, moveItem])
373+
}, [entityType, store, dispatcher, schemaRegistry, selectionMeta, addItem, removeItem, moveItem])
372374

373375
const isEqual = useCallback(
374376
(a: UseEntityListResult<any>, b: UseEntityListResult<any>): boolean => {

packages/bindx/src/handles/EntityHandle.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type {
2626
import { HasOneHandle } from './HasOneHandle.js'
2727
import { HasManyListHandle } from './HasManyListHandle.js'
2828
import { createHandleProxy } from './proxyFactory.js'
29-
import type { SelectionMeta } from '../selection/types.js'
29+
import type { SelectionMeta, SelectionFieldMeta } from '../selection/types.js'
3030
import { UnfetchedFieldError } from '../errors/UnfetchedFieldError.js'
3131

3232
/** Minimal internal interface for cached relation handles that need reset.
@@ -454,12 +454,14 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
454454
get fields(): EntityFieldsAccessor<T, TSelected> {
455455
return new Proxy({} as EntityFieldsAccessor<T, TSelected>, {
456456
get: (_, fieldName: string) => {
457+
const fieldMeta = this.resolveSelectionField(fieldName)
458+
457459
// Selection validation
458-
if (this.selection && !this.selection.fields.has(fieldName)) {
460+
if (this.selection && !fieldMeta) {
459461
throw new UnfetchedFieldError(this.entityType, this.entityId, [fieldName])
460462
}
461463

462-
const nestedSelection = this.selection?.fields.get(fieldName)?.nested
464+
const nestedSelection = fieldMeta?.nested
463465

464466
// Use schema to determine field type
465467
const fieldDef = this.schema.getFieldDef(this.entityType, fieldName)
@@ -475,8 +477,10 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
475477
}
476478

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

482486
// Unknown field type - fallback to FieldHandle
@@ -485,6 +489,30 @@ export class EntityHandle<T extends object = object, TSelected = T> extends Enti
485489
})
486490
}
487491

492+
/**
493+
* Resolves the selection metadata for an accessed property name.
494+
*
495+
* Direct lookup covers scalars, has-one, plain has-many, and explicit-alias access.
496+
* A has-many selected with params (filter/orderBy/limit/offset) is keyed by an
497+
* auto-generated alias (e.g. `tags_<hash>`) while consumers still read it by the
498+
* real field name (`tags`). For that case fall back to matching on `fieldName` so
499+
* the stored alias is recovered. The fallback is restricted to array relations:
500+
* explicit scalar/has-one aliases are addressed by their chosen alias and must
501+
* still report as unfetched when read by their original name.
502+
*/
503+
private resolveSelectionField(name: string): SelectionFieldMeta | undefined {
504+
if (!this.selection) return undefined
505+
506+
const direct = this.selection.fields.get(name)
507+
if (direct) return direct
508+
509+
for (const meta of this.selection.fields.values()) {
510+
if (meta.isArray && meta.fieldName === name) return meta
511+
}
512+
513+
return undefined
514+
}
515+
488516
/**
489517
* Type brand - ensures EntityRef<Author> is not assignable to EntityRef<Tag>.
490518
* This is a phantom property that only exists in the type system.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import '../../../setup'
2+
// Regression test for https://github.com/contember/bindx/issues/53 (nested hasMany with orderBy reads empty from a single-entity accessor)
3+
import { afterEach, describe, expect, test } from 'bun:test'
4+
import { cleanup, render, waitFor } from '@testing-library/react'
5+
import React from 'react'
6+
import { BindxProvider, MockAdapter, defineSchema, entityDef, hasMany, scalar, useEntity } from '@contember/bindx-react'
7+
8+
afterEach(() => {
9+
cleanup()
10+
})
11+
12+
interface Tag {
13+
id: string
14+
name: string
15+
order: number
16+
}
17+
18+
interface Article {
19+
id: string
20+
title: string
21+
tags: Tag[]
22+
}
23+
24+
interface TestSchema {
25+
Article: Article
26+
Tag: Tag
27+
}
28+
29+
const schema = defineSchema<TestSchema>({
30+
entities: {
31+
Article: {
32+
fields: {
33+
id: scalar(),
34+
title: scalar(),
35+
tags: hasMany('Tag'),
36+
},
37+
},
38+
Tag: {
39+
fields: {
40+
id: scalar(),
41+
name: scalar(),
42+
order: scalar(),
43+
},
44+
},
45+
},
46+
})
47+
48+
const entityDefs = {
49+
Article: entityDef<Article>('Article'),
50+
Tag: entityDef<Tag>('Tag'),
51+
} as const
52+
53+
function createMockData() {
54+
return {
55+
Article: {
56+
'article-1': {
57+
id: 'article-1',
58+
title: 'Test Article',
59+
tags: [
60+
{ id: 'tag-2', name: 'React', order: 1 },
61+
{ id: 'tag-1', name: 'JavaScript', order: 0 },
62+
],
63+
},
64+
},
65+
}
66+
}
67+
68+
function queryByTestId(container: Element, testId: string): Element | null {
69+
return container.querySelector(`[data-testid="${testId}"]`)
70+
}
71+
72+
describe('useEntity nested hasMany with orderBy', () => {
73+
test('should expose nested hasMany items (ordered) when the relation is selected WITH orderBy', async () => {
74+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
75+
76+
function TestComponent(): React.ReactElement {
77+
const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, a =>
78+
a.id().title().tags({ orderBy: [{ order: 'asc' }] }, t => t.id().name()),
79+
)
80+
if (article.$status !== 'ready') return <div data-testid="loading">Loading...</div>
81+
return (
82+
<div>
83+
<span data-testid="title">{article.title.value}</span>
84+
<span data-testid="tag-count">{article.tags.items.length}</span>
85+
<span data-testid="tag-names">{article.tags.items.map(t => t.name.value).join(',')}</span>
86+
</div>
87+
)
88+
}
89+
90+
const { container } = render(
91+
<BindxProvider adapter={adapter} schema={schema}>
92+
<TestComponent />
93+
</BindxProvider>,
94+
)
95+
96+
await waitFor(() => {
97+
expect(queryByTestId(container, 'title')).not.toBeNull()
98+
})
99+
100+
expect(container.querySelector('[data-testid="title"]')?.textContent).toBe('Test Article')
101+
expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
102+
// orderBy must be honored on the read path: order asc -> JavaScript (0) before React (1)
103+
expect(container.querySelector('[data-testid="tag-names"]')?.textContent).toBe('JavaScript,React')
104+
})
105+
106+
test('control: nested hasMany WITHOUT orderBy exposes items', async () => {
107+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
108+
109+
function TestComponent(): React.ReactElement {
110+
const article = useEntity(entityDefs.Article, { by: { id: 'article-1' } }, a =>
111+
a.id().title().tags(t => t.id().name()),
112+
)
113+
if (article.$status !== 'ready') return <div data-testid="loading">Loading...</div>
114+
return <span data-testid="tag-count">{article.tags.items.length}</span>
115+
}
116+
117+
const { container } = render(
118+
<BindxProvider adapter={adapter} schema={schema}>
119+
<TestComponent />
120+
</BindxProvider>,
121+
)
122+
123+
await waitFor(() => {
124+
expect(queryByTestId(container, 'tag-count')).not.toBeNull()
125+
})
126+
127+
expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
128+
})
129+
})
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import '../../../setup'
2+
// Regression test for https://github.com/contember/bindx/issues/53 (nested hasMany with orderBy reads empty from a list-loaded accessor)
3+
import { afterEach, describe, expect, test } from 'bun:test'
4+
import { cleanup, render, waitFor } from '@testing-library/react'
5+
import React from 'react'
6+
import { BindxProvider, MockAdapter, defineSchema, entityDef, hasMany, scalar, useEntityList } from '@contember/bindx-react'
7+
8+
afterEach(() => {
9+
cleanup()
10+
})
11+
12+
interface Tag {
13+
id: string
14+
name: string
15+
order: number
16+
}
17+
18+
interface Article {
19+
id: string
20+
title: string
21+
tags: Tag[]
22+
}
23+
24+
interface TestSchema {
25+
Article: Article
26+
Tag: Tag
27+
}
28+
29+
const schema = defineSchema<TestSchema>({
30+
entities: {
31+
Article: {
32+
fields: {
33+
id: scalar(),
34+
title: scalar(),
35+
tags: hasMany('Tag'),
36+
},
37+
},
38+
Tag: {
39+
fields: {
40+
id: scalar(),
41+
name: scalar(),
42+
order: scalar(),
43+
},
44+
},
45+
},
46+
})
47+
48+
const entityDefs = {
49+
Article: entityDef<Article>('Article'),
50+
Tag: entityDef<Tag>('Tag'),
51+
} as const
52+
53+
function createMockData() {
54+
return {
55+
Article: {
56+
'article-1': {
57+
id: 'article-1',
58+
title: 'Test Article',
59+
tags: [
60+
{ id: 'tag-1', name: 'JavaScript', order: 0 },
61+
{ id: 'tag-2', name: 'React', order: 1 },
62+
],
63+
},
64+
},
65+
}
66+
}
67+
68+
function queryByTestId(container: Element, testId: string): Element | null {
69+
return container.querySelector(`[data-testid="${testId}"]`)
70+
}
71+
72+
// The nested hasMany `tags` is selected with an `orderBy` argument. The adapter returns the two tags
73+
// (verifiable by logging the raw query result), but reading `list.items[0].tags.items` yields an empty
74+
// array — the relation's scalar siblings (title) load fine, only the ordered hasMany comes back empty.
75+
describe('useEntityList nested hasMany with orderBy', () => {
76+
test('should expose nested hasMany items when the relation is selected WITH orderBy', async () => {
77+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
78+
79+
function TestComponent(): React.ReactElement {
80+
const list = useEntityList(entityDefs.Article, { filter: { id: { eq: 'article-1' } } }, a =>
81+
a.id().title().tags({ orderBy: [{ order: 'asc' }] }, t => t.id().name()),
82+
)
83+
if (list.$status !== 'ready') return <div data-testid="loading">Loading...</div>
84+
const article = list.items[0]
85+
return (
86+
<div>
87+
<span data-testid="title">{article?.title.value}</span>
88+
<span data-testid="tag-count">{article ? article.tags.items.length : -1}</span>
89+
</div>
90+
)
91+
}
92+
93+
const { container } = render(
94+
<BindxProvider adapter={adapter} schema={schema}>
95+
<TestComponent />
96+
</BindxProvider>,
97+
)
98+
99+
await waitFor(() => {
100+
expect(queryByTestId(container, 'title')).not.toBeNull()
101+
})
102+
103+
expect(container.querySelector('[data-testid="title"]')?.textContent).toBe('Test Article')
104+
// Bug: reads '0'. The two ordered tags are dropped from the accessor.
105+
expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
106+
})
107+
108+
test('control: nested hasMany WITHOUT orderBy exposes items', async () => {
109+
const adapter = new MockAdapter(createMockData(), { delay: 0 })
110+
111+
function TestComponent(): React.ReactElement {
112+
const list = useEntityList(entityDefs.Article, { filter: { id: { eq: 'article-1' } } }, a =>
113+
a.id().title().tags(t => t.id().name()),
114+
)
115+
if (list.$status !== 'ready') return <div data-testid="loading">Loading...</div>
116+
const article = list.items[0]
117+
return <span data-testid="tag-count">{article ? article.tags.items.length : -1}</span>
118+
}
119+
120+
const { container } = render(
121+
<BindxProvider adapter={adapter} schema={schema}>
122+
<TestComponent />
123+
</BindxProvider>,
124+
)
125+
126+
await waitFor(() => {
127+
expect(queryByTestId(container, 'tag-count')).not.toBeNull()
128+
})
129+
130+
expect(container.querySelector('[data-testid="tag-count"]')?.textContent).toBe('2')
131+
})
132+
})

0 commit comments

Comments
 (0)