Skip to content

Filtered HasMany also fetches the unfiltered relation #158

Open

Description

@vparys

Summary

A <HasMany> with filter / orderBy / limit / offset fetches its filtered subquery and, next to it, the plain unfiltered relation with the same nested fields. Every filtered or paginated has-many therefore also loads the whole relation.

Environment

Reproduction

<Entity entity={schema.Article} by={{ id: 'article-1' }}>
	{article => (
		<HasMany field={article.tags} filter={{ name: { eq: 'React' } }}>
			{tag => <Field field={tag.name} />}
		</HasMany>
	)}
</Entity>

The get query spec for Article contains two tags fields.

Expected behavior

One field: the aliased subquery with the filter, tags_<hash>(filter: { name: { eq: 'React' } }) { id name }.

Actual behavior

{ name: 'tags', filter: undefined }            // plain, unfiltered tags { id name }
{ name: 'tags_g6fozb', filter: { name: { eq: 'React' } } }

On a real relation this loads every row of the relation that the UI only shows filtered or one page of; on a large has-many (comments, enrollments, …) it is the expensive half of the query.

Suspected root cause

HasMany.getSelection (packages/bindx-react/src/jsx/components/HasMany.tsx, around line 77) calls props.field.map(...) on the field it receives during collection, to get a collector entity for the children:

fullField.map((item, index) => {
	const scopeRef = (item as unknown as Record<symbol, unknown>)[SCOPE_REF]
	...
	result.syntheticChildren = props.children(item, index)
	return null
})

That field is the outer collector proxy, so map registers the relation under its plain name in the outer SelectionScope and the children's reads land in that scope too. getSelection then also returns the aliased field with hasManyParams, and the analyzer merges both into the root selection.

Suggested fix

Collect the children against a detached scope instead of the outer collector: create a fresh SelectionScope (and collector entity for the target entity) inside getSelection, run props.children against it, and return only the aliased field. Alternatively, have the collector's map take the has-many params so it registers the aliased field instead of the plain one. Deferring to maintainers on which fits the collector design.

Found while working on #156.

Activity

  1. vparys commented on Nov 3, 2026

    @vparys
    MemberAuthor

    Digging into the fix showed this is worse than an over-fetch: the plain relation is also what <HasMany> renders. HasManyImpl reads field as given, and EntityHandle.resolveSelectionField('tags') prefers the direct plain tags over the params alias, so a filtered list shows the whole relation (filter={{ name: { eq: 'React' } }} renders "JavaScriptReact"). The aliased subquery is fetched and never read. Also item 2 of docs/issues/036.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions