diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index dae232c8..f0f61aa9 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -65,8 +65,8 @@ type StaticFieldNode = assumesNotHasOne?: true // a ref API value (`.value`, `.errors`) left the analyzed code; // the runtime falls back when the schema says has-one } -// Has-many params are not part of the format: the runtime collector ignores them inside a -// component, and an root with a parameterised bails (HASMANY_PARAMS_AT_ROOT). +// Has-many params are not part of the format: a parameterised is collected and +// rendered under its params alias, so the component or root holding it bails (HASMANY_PARAMS). /** Emitted as the 2nd argument of .render(): key = implicit entity prop name. */ type StaticSelection = Record diff --git a/docs/selection-collection.md b/docs/selection-collection.md index 6cc53dd0..261c2092 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -461,7 +461,7 @@ fields) is acceptable; under-approximation is impossible by construction (defaul deny). Anything it cannot classify makes the whole component **bail** with a machine-readable reason (e.g. `ENTITY_ESCAPES_TO_CALL`, `ENTITY_IN_EXPRESSION_PROP`, `FUNCTION_PROP_ON_HOLE`, `RENDER_LOCAL_ON_HOLE`, `ENTITY_REASSIGNMENT`, `ENTITY_SPREAD`, -`COMPUTED_MEMBER`, `MEMBER_COMPONENT_TAG`, `ENTITY_ESCAPES_TO_VALUE`, `HASMANY_PARAMS_AT_ROOT`, `INTERFACES_MODE`, +`COMPUTED_MEMBER`, `MEMBER_COMPONENT_TAG`, `ENTITY_ESCAPES_TO_VALUE`, `HASMANY_PARAMS`, `INTERFACES_MODE`, `UNCLASSIFIED`). Bailed chains are left untouched and fall back to the runtime proxy pass. `FUNCTION_PROP_ON_HOLE` / `RENDER_LOCAL_ON_HOLE` guard an under-fetch class: a hole element's diff --git a/packages/bindx-client/src/selection/SelectionScope.ts b/packages/bindx-client/src/selection/SelectionScope.ts index d02885b8..93b9424f 100644 --- a/packages/bindx-client/src/selection/SelectionScope.ts +++ b/packages/bindx-client/src/selection/SelectionScope.ts @@ -16,6 +16,8 @@ export interface HasManyParams { * Relation metadata stored at parent level */ interface RelationMeta { + /** The schema field; the child's key differs from it for a has-many keyed by its params alias. */ + fieldName: string isArray: boolean params?: HasManyParams } @@ -57,7 +59,35 @@ export class SelectionScope { childScope.parent = this childScope.addScalar('id') this.children.set(fieldName, childScope) - this.relationMeta.set(fieldName, { isArray: false }) + this.relationMeta.set(fieldName, { fieldName, isArray: false }) + } + return childScope + } + + /** + * Create or get the child scope of a has-many read with parameters. It is keyed by the + * params alias (`generateHasManyAlias`), next to and independent of the plain relation's + * child, so a filtered or paginated list selects its fields only under its own alias and + * two lists over the same field with different parameters stay apart. + */ + hasManyChild(fieldName: string, params: HasManyParams): SelectionScope { + const alias = generateHasManyAlias(fieldName, params) + if (alias === fieldName) { + const plain = this.child(fieldName) + this.markAsArray(fieldName) + return plain + } + let childScope = this.children.get(alias) + if (!childScope) { + childScope = new SelectionScope() + childScope.parent = this + childScope.addScalar('id') + this.children.set(alias, childScope) + this.relationMeta.set(alias, { fieldName, isArray: true, params }) + } else if (params.totalCount) { + // totalCount is not part of the alias: one reader asking for it is enough. + const meta = this.relationMeta.get(alias)! + meta.params = { ...meta.params, totalCount: true } } return childScope } @@ -110,14 +140,18 @@ export class SelectionScope { } // Merge relation children - for (const [fieldName, otherChild] of other.children) { - const thisChild = this.child(fieldName) + for (const [key, otherChild] of other.children) { + const otherMeta = other.relationMeta.get(key) + if (otherMeta && key !== otherMeta.fieldName && otherMeta.params) { + this.hasManyChild(otherMeta.fieldName, otherMeta.params).merge(otherChild) + continue + } + const thisChild = this.child(key) thisChild.merge(otherChild) // Merge relation metadata - const otherMeta = other.relationMeta.get(fieldName) if (otherMeta) { - const thisMeta = this.relationMeta.get(fieldName)! + const thisMeta = this.relationMeta.get(key)! if (otherMeta.isArray) { thisMeta.isArray = true } @@ -137,7 +171,9 @@ export class SelectionScope { // Only process root-level fields if (field.path.length !== 1) continue - if (field.isRelation && field.nested) { + if (field.isRelation && field.nested && field.isArray && field.hasManyParams && field.alias !== field.fieldName) { + this.hasManyChild(field.fieldName, field.hasManyParams).mergeFromSelectionMeta(field.nested) + } else if (field.isRelation && field.nested) { const childScope = this.child(field.fieldName) if (field.isArray) { this.markAsArray(field.fieldName) @@ -174,13 +210,14 @@ export class SelectionScope { } // Add relation fields with nested selections - for (const [fieldName, childScope] of this.children) { - const meta = this.relationMeta.get(fieldName)! + for (const [key, childScope] of this.children) { + const meta = this.relationMeta.get(key)! + const fieldName = meta.fieldName // Generate alias for has-many fields with params const alias = meta.isArray && meta.params ? generateHasManyAlias(fieldName, meta.params) - : fieldName + : key fields.set(alias, { fieldName, diff --git a/packages/bindx-compiler/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts index abf194fe..fe4bd4d4 100644 --- a/packages/bindx-compiler/src/analyze.ts +++ b/packages/bindx-compiler/src/analyze.ts @@ -104,7 +104,7 @@ function analyzeChain( return { loc, bailout: chain.earlyBail } } const propRoots = new Map(chain.entityProps.map(prop => [prop, new SelNode()])) - const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds, false) + const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds) try { if (chain.conditionFn) { analyzer.analyzeFunction(chain.conditionFn, propRoots) diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts index 85ba5e58..f182906c 100644 --- a/packages/bindx-compiler/src/body.ts +++ b/packages/bindx-compiler/src/body.ts @@ -33,9 +33,8 @@ export class BodyAnalyzer { moduleScope: ModuleScope, contracts: ContractLookup, targetKinds: TargetKindLookup, - isSelectionRoot: boolean, ) { - this.jsx = new JsxAnalyzer(this, bindings, moduleScope, contracts, targetKinds, isSelectionRoot) + this.jsx = new JsxAnalyzer(this, bindings, moduleScope, contracts, targetKinds) } /** Public so JsxAnalyzer can register a hole it discovered. */ diff --git a/packages/bindx-compiler/src/entityRoots.ts b/packages/bindx-compiler/src/entityRoots.ts index cafb36b2..3ce2bb32 100644 --- a/packages/bindx-compiler/src/entityRoots.ts +++ b/packages/bindx-compiler/src/entityRoots.ts @@ -113,7 +113,7 @@ export function analyzeEntityRoot( } const rootNode = new SelNode() - const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds, true) + const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds) try { analyzer.analyzeRootChildren(childrenFn, rootNode, ENTITY_ROOT_KEY) } catch (error) { diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts index e5839aac..5c087f0e 100644 --- a/packages/bindx-compiler/src/jsx.ts +++ b/packages/bindx-compiler/src/jsx.ts @@ -42,8 +42,6 @@ export class JsxAnalyzer { private readonly moduleScope: ModuleScope, private readonly contracts: ContractLookup, private readonly targetKinds: TargetKindLookup, - /** Analyzing an `` root: the runtime walk there also keeps what components return. */ - private readonly isSelectionRoot: boolean, ) {} walk(node: t.JSXElement | t.JSXFragment, scope: Scope): void { @@ -392,10 +390,13 @@ export class JsxAnalyzer { } return // no entity reaches this element → nothing it collects belongs to this selection } - if (many && this.isSelectionRoot && HASMANY_PARAM_KEYS.some(key => findAttr(node, key) !== null)) { - // The root walk keeps 's returned, param-aliased relation next to the plain one; - // a field map holds one node per field. Inside a component the runtime ignores params. - throw new BailError({ code: 'HASMANY_PARAMS_AT_ROOT', message: ' with params in an root' }) + const mayHaveParams = HASMANY_PARAM_KEYS.some(key => findAttr(node, key) !== null) + || node.openingElement.attributes.some(attr => t.isJSXSpreadAttribute(attr)) + if (many && mayHaveParams) { + // The runtime collects a parameterised under its params alias, next to the + // plain relation, and renders that alias; a field map holds one node per field. A + // spread may carry params, so it counts as having them. + throw new BailError({ code: 'HASMANY_PARAMS', message: ' with params' }) } const item = many ? consumeMany(res.ref) : consumeRelation(res.ref) const children = childrenSlot(node) diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts index 29742769..134a5e8d 100644 --- a/packages/bindx-compiler/src/types.ts +++ b/packages/bindx-compiler/src/types.ts @@ -67,7 +67,7 @@ export type BailoutReason = | 'MEMBER_COMPONENT_TAG' | 'ENTITY_SPREAD' | 'COMPUTED_MEMBER' - | 'HASMANY_PARAMS_AT_ROOT' + | 'HASMANY_PARAMS' | 'ENTITY_ESCAPES_TO_VALUE' | 'ENTITY_REASSIGNMENT' | 'ENTITY_NO_FUNCTION_CHILDREN' diff --git a/packages/bindx-compiler/tests/analyzer.test.ts b/packages/bindx-compiler/tests/analyzer.test.ts index 72f264ba..282d1a2f 100644 --- a/packages/bindx-compiler/tests/analyzer.test.ts +++ b/packages/bindx-compiler/tests/analyzer.test.ts @@ -19,21 +19,10 @@ function selectionOf(code: string): Record { const PRELUDE = `import { createComponent, Field, HasMany, HasOne } from '@contember/bindx-react'\nimport { schema } from './s'\n` describe('StaticSelection emission (raw, not normalized)', () => { - test('has-many params are not emitted (the runtime ignores them inside a component)', () => { + test('a component with a parameterised bails (the runtime collects it under its params alias)', () => { const results = analyzeFixture(DIR, 'hasManyParams.tsx') const result = results[0]! - expect(isBailed(result)).toBe(false) - if (isBailed(result)) { - return - } - expect(result.selection).toEqual({ - article: { - tags: { - fields: { id: true, name: true, color: true }, - many: true, - }, - }, - }) + expect(isBailed(result) ? result.bailout.code : null).toBe('HASMANY_PARAMS') }) test('scalar leaf is `true`, has-one relation nests with id', () => { @@ -88,9 +77,14 @@ describe('bail triggers (direct)', () => { expect(bail(`({ article }) => `)).toBe('COMPUTED_MEMBER') }) - test('HasMany params inside a component are ignored, like the runtime collector', () => { + test('HasMany with spread props, which may carry params', () => { + expect(bail(`({ article }) => {t => }`)) + .toBe('HASMANY_PARAMS') + }) + + test('HasMany params inside a component', () => { expect(bail(`({ article }) => {t => }`)) - .toBeNull() + .toBe('HASMANY_PARAMS') }) test('HasMany params in an root', () => { @@ -100,7 +94,7 @@ describe('bail triggers (direct)', () => { + `{t => }}`, 'root.tsx', ) - expect(root && isEntityRootBailed(root) ? root.bailout.code : null).toBe('HASMANY_PARAMS_AT_ROOT') + expect(root && isEntityRootBailed(root) ? root.bailout.code : null).toBe('HASMANY_PARAMS') }) }) diff --git a/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx b/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx index 2a555f7a..78b60e08 100644 --- a/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx +++ b/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx @@ -2,9 +2,8 @@ import { createComponent, Field, HasMany } from '@contember/bindx-react' import type { FixtureCase } from '../fixtureTypes.js' import { schema } from './_schema.js' -// Has-many with literal params. The runtime collector drops params in the implicit -// path (verified against the oracle), so field-tree equality holds; params are -// asserted separately in analyzer.test.ts. +// Has-many with literal params. The runtime collects it under its params alias, which the +// field-map format cannot hold, so the chain bails to the runtime pass. export const HasManyParams = createComponent() .entity('article', schema.Article) .render(({ article }) => ( @@ -19,5 +18,5 @@ export const HasManyParams = createComponent() )) export const cases: FixtureCase[] = [ - { component: HasManyParams, prop: 'article' }, + { component: HasManyParams, prop: 'article', expect: 'bail', code: 'HASMANY_PARAMS' }, ] diff --git a/packages/bindx-compiler/tests/plugin.test.ts b/packages/bindx-compiler/tests/plugin.test.ts index 98cc488b..8ec95d4f 100644 --- a/packages/bindx-compiler/tests/plugin.test.ts +++ b/packages/bindx-compiler/tests/plugin.test.ts @@ -25,7 +25,7 @@ export const Card = createComponent()
{a => } - {tag => } + {tag => }
)) ` @@ -41,7 +41,6 @@ describe('babel plugin injection', () => { expect(output).toContain('author: {') expect(output).toContain('fields: {') expect(output).toContain('many: true') - expect(output).not.toContain('params:') // the runtime ignores params inside a component // This chain has no holes → the holes key is omitted. expect(output).not.toContain('holes:') }) diff --git a/packages/bindx-react/src/jsx/collectorProxy.ts b/packages/bindx-react/src/jsx/collectorProxy.ts index 4832b4c2..ca60f2eb 100644 --- a/packages/bindx-react/src/jsx/collectorProxy.ts +++ b/packages/bindx-react/src/jsx/collectorProxy.ts @@ -1,5 +1,5 @@ import type { SchemaRegistry } from '@contember/bindx' -import { SelectionScope, generatePlaceholderId } from '@contember/bindx' +import { SelectionScope, generatePlaceholderId, type HasManyParams } from '@contember/bindx' import { type EntityAccessor, type EntityFields, @@ -11,6 +11,8 @@ import { type HasOneAccessor, FIELD_REF_META, SCOPE_REF, + HAS_MANY_PARAMS_COLLECTOR, + type HasManyParamsCollector, } from './types.js' import { wrapEntityRefWithFieldAccessProxy, REACT_ELEMENT_PROBE_KEYS } from './proxyShared.js' @@ -184,6 +186,10 @@ function createCollectorFieldRef( get [SCOPE_REF](): SelectionScope { return getChildScope() }, + [HAS_MANY_PARAMS_COLLECTOR]: (params: HasManyParams): HasManyParamsCollector => { + const scope = parentScope.hasManyChild(fieldName, params) + return { item: createCollectorProxy(scope, targetEntityName, schemaRegistry), scope } + }, // FieldRef properties (non-$ versions) value: null, diff --git a/packages/bindx-react/src/jsx/components/HasMany.tsx b/packages/bindx-react/src/jsx/components/HasMany.tsx index 0ce5c93f..14c9b866 100644 --- a/packages/bindx-react/src/jsx/components/HasMany.tsx +++ b/packages/bindx-react/src/jsx/components/HasMany.tsx @@ -1,8 +1,16 @@ import React, { memo, type ReactElement, type ReactNode } from 'react' import type { HasManyProps, SelectionFieldMeta, SelectionMeta, SelectionProvider, AnyBrand } from '../types.js' -import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from '../types.js' +import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF, HAS_MANY_PARAMS_COLLECTOR, type HasManyParamsCollector } from '../types.js' import { mergeSelections } from '../SelectionMeta.js' -import { SelectionScope, generateHasManyAlias, type HasManyAccessor } from '@contember/bindx' +import { + SelectionScope, + generateHasManyAlias, + HAS_MANY_ALIAS_RESOLVER, + type HasManyAccessor, + type HasManyAliasResolver, + type HasManyParams, + type HasManyRef, +} from '@contember/bindx' import { useHasMany } from '../../hooks/useHasMany.js' import { annotateElement } from '../devAnnotations.js' @@ -36,9 +44,19 @@ function HasManyImpl< TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record = Record, ->({ field, children }: HasManyProps): ReactElement { +>({ field, children, filter, orderBy, limit, offset }: HasManyProps): ReactElement { + // With parameters the data was fetched under the params alias (see getSelection); the + // field the children received reads the plain relation, so switch to that alias. A + // selection that did not fetch the alias (a `useEntity` definer, params changed since + // collection) leaves the field as given. + const resolveAlias = (field as unknown as { [HAS_MANY_ALIAS_RESOLVER]?: HasManyAliasResolver })[HAS_MANY_ALIAS_RESOLVER] + const aliasList = resolveAlias?.({ filter, orderBy, limit, offset }) + const listRef = aliasList + ? aliasList as unknown as HasManyRef + : field + // useHasMany() subscribes to store and returns HasManyAccessor with .map()/.items/.length - const accessor = useHasMany(field) + const accessor = useHasMany(listRef) const targetType = field[FIELD_REF_META]?.targetType const items = accessor.map((item, index) => { @@ -67,22 +85,36 @@ hasManyWithSelection.getSelection = ( // During collection, field is a collector proxy with all accessor properties const fullField = props.field as unknown as HasManyAccessor - // Use field's map function to get a properly configured collector with schema info - // The map() creates a collector that knows about entity types and relations const result: { syntheticChildren: ReactNode; childScope: SelectionScope | null } = { syntheticChildren: null, childScope: null, } - fullField.map((item, index) => { - // Get the scope from the collector entity (it has SCOPE_REF) - const scopeRef = (item as unknown as Record)[SCOPE_REF] - if (scopeRef instanceof SelectionScope) { - result.childScope = scopeRef - } - result.syntheticChildren = props.children(item, index) - return null - }) + // With parameters the children are collected into the parent scope's child for the + // params alias. Collecting through `field.map()` would write them into the plain + // relation, so a filtered or paginated list would fetch the whole relation as well. + // Without parameters the plain relation is this field's own place, and `map()` uses it. + const params = { filter: props.filter, orderBy: props.orderBy, limit: props.limit, offset: props.offset } + const hasParams = params.filter !== undefined || params.orderBy !== undefined || params.limit !== undefined || params.offset !== undefined + const collectWithParams = (props.field as unknown as { [HAS_MANY_PARAMS_COLLECTOR]?: (params: HasManyParams) => HasManyParamsCollector })[ + HAS_MANY_PARAMS_COLLECTOR + ] + if (hasParams && collectWithParams) { + const { item, scope } = collectWithParams(params) + result.childScope = scope + result.syntheticChildren = props.children(item, 0) + } else { + // No params, or a field that is not a collector proxy: map() yields a collector + // item that knows the target entity and writes into the plain relation. + fullField.map((item, index) => { + const scopeRef = (item as unknown as Record)[SCOPE_REF] + if (scopeRef instanceof SelectionScope) { + result.childScope = scopeRef + } + result.syntheticChildren = props.children(item, index) + return null + }) + } // Also analyze the JSX structure const jsxSelection = result.syntheticChildren ? collectNested(result.syntheticChildren) : { fields: new Map() } diff --git a/packages/bindx-react/src/jsx/types.ts b/packages/bindx-react/src/jsx/types.ts index 72d09eef..dd055153 100644 --- a/packages/bindx-react/src/jsx/types.ts +++ b/packages/bindx-react/src/jsx/types.ts @@ -18,6 +18,7 @@ import type { EntityRef, EntityAccessor, AnyBrand, + SelectionScope, } from '@contember/bindx' import { Condition } from './conditions.js' @@ -57,6 +58,21 @@ export const BINDX_COMPONENT = Symbol('BINDX_COMPONENT') */ export const SCOPE_REF = Symbol('SCOPE_REF') +/** + * Symbol for a has-many collector ref's params item collector: given has-many parameters, + * it returns a collector item over the parent scope's child for that params alias + * (`SelectionScope.hasManyChild`). `` collects its children through it, + * so a parameterised list registers its fields only under its own alias and not also on + * the plain, unfiltered relation. + */ +export const HAS_MANY_PARAMS_COLLECTOR = Symbol('HAS_MANY_PARAMS_COLLECTOR') + +/** What a {@link HAS_MANY_PARAMS_COLLECTOR} function returns. */ +export interface HasManyParamsCollector { + readonly item: EntityAccessor + readonly scope: SelectionScope +} + /** * Props for Field component. diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index a9b1289b..30208496 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -13,7 +13,8 @@ import { } from '../core/actions.js' import { FIELD_REF_META, type FieldRefMeta, type FieldAccessor, type HasOneAccessor, type HasManyAccessor, type EntityFieldsAccessor, type Unsubscribe, type EntityAccessor } from './types.js' import { deepEqual } from '../utils/deepEqual.js' -import { canHasManyParamsExcludeMembers } from '../utils/aliasGenerator.js' +import { canHasManyParamsExcludeMembers, generateHasManyAlias } from '../utils/aliasGenerator.js' +import type { HasManyParams } from '../selection/index.js' import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js' import type { EventTypeMap, @@ -372,12 +373,56 @@ export class EntityHandle extends Enti effectiveAlias, nestedSelection, [...this.entityPath, fieldName], + params => this.resolveHasManyAlias(fieldName, params), ) this.relationHandleCache.set(cacheKey, { raw: handle as unknown as RelationHandleRaw, proxy: handle }) return handle } + /** + * The has-many `fieldName` read with `params`, under their alias, when that alias was + * fetched: per the selection when the entity has one, per its stored data when it has none + * (a `HasManyDataGrid` row). Otherwise `undefined`, so the caller keeps reading the relation + * it already has; in create mode, for one, items added to the relation then still show. + */ + private resolveHasManyAlias(fieldName: string, params: HasManyParams): HasManyAccessor | undefined { + const alias = generateHasManyAlias(fieldName, params) + if (alias === fieldName) { + return undefined + } + if (!this.selection) { + const data = this.store.getEntitySnapshot(this.entityType, this.entityId)?.data + if (!data || !(alias in data)) { + return undefined + } + this.declareAliasViewMembership(fieldName, alias, params) + return this.hasMany(fieldName, alias) + } + const meta = this.selection.fields.get(alias) + if (!meta || meta.fieldName !== fieldName) { + return undefined + } + this.declareAliasViewMembership(fieldName, alias, meta.hasManyParams) + return this.hasMany(fieldName, alias, meta.nested) + } + + /** + * The alias is a hash of the params, so the store cannot tell a filtered view from one that + * only orders; declare which it is where the params are known, so an orderBy-only view keeps + * showing items added to the relation. + */ + private declareAliasViewMembership(fieldName: string, alias: string, params: HasManyParams | undefined): void { + if (alias !== fieldName) { + this.store.declareHasManyViewMembership( + this.entityType, + fieldName, + alias, + canHasManyParamsExcludeMembers(params) ? 'partial' : 'total', + ) + } + } + /** * Gets a raw (unproxied) has-one relation handle from cache. * Creates the handle if not cached yet. @@ -512,15 +557,8 @@ export class EntityHandle extends Enti // 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. - // The alias is a hash of the params, so the store cannot tell a filtered - // view from one that only orders — say so here, where the params are. - if (fieldMeta && fieldMeta.alias !== schemaFieldName) { - this.store.declareHasManyViewMembership( - this.entityType, - schemaFieldName, - fieldMeta.alias, - canHasManyParamsExcludeMembers(fieldMeta.hasManyParams) ? 'partial' : 'total', - ) + if (fieldMeta) { + this.declareAliasViewMembership(schemaFieldName, fieldMeta.alias, fieldMeta.hasManyParams) } return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection) } diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index f0e9ef9c..9058f830 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -5,6 +5,7 @@ import type { SnapshotStore } from '../store/SnapshotStore.js' import type { HasManyRemovalType } from '../store/RelationStore.js' import type { SchemaRegistry } from '../schema/SchemaRegistry.js' import type { SelectionMeta } from '../selection/types.js' +import type { HasManyParams } from '../selection/index.js' import { connectToList, addToList, @@ -25,6 +26,18 @@ import type { } from '../events/types.js' import { createAliasProxy } from './proxyFactory.js' +/** + * Symbol for a has-many handle's alias resolver: given has-many parameters, it returns the + * handle of the same relation under their alias (`generateHasManyAlias`), which reads the + * data fetched with them, or `undefined` when the parameters make no alias or the parent's + * selection did not fetch it. `` uses it so the list it renders is the + * filtered one, not the plain relation. + */ +export const HAS_MANY_ALIAS_RESOLVER = Symbol('HAS_MANY_ALIAS_RESOLVER') + +/** Resolves a sibling alias of the same has-many relation; see {@link HAS_MANY_ALIAS_RESOLVER}. */ +export type HasManyAliasResolver = (params: HasManyParams) => HasManyAccessor | undefined + /** * HasManyListHandle provides access to a has-many relation (list of entities). * Implements HasManyRef interface for JSX compatibility. @@ -43,6 +56,9 @@ export class HasManyListHandle + /** Present when the parent entity can hand out this relation under another alias. */ + readonly [HAS_MANY_ALIAS_RESOLVER]?: HasManyAliasResolver + /** * Type brand - entity name for type inference. * Implements HasManyRef.__entityName. @@ -80,10 +96,12 @@ export class HasManyListHandle( @@ -98,9 +116,13 @@ export class HasManyListHandle { return createAliasProxy, HasManyAccessor>( - HasManyListHandle.createRaw(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath), + HasManyListHandle.createRaw( + parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, + brands, alias, selection, relationPath, resolveAlias, + ), ) } @@ -120,8 +142,11 @@ export class HasManyListHandle { - return new HasManyListHandle(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath) + return new HasManyListHandle( + parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath, resolveAlias, + ) } /** diff --git a/packages/bindx/src/handles/index.ts b/packages/bindx/src/handles/index.ts index e2fc2c67..2d40dd5e 100644 --- a/packages/bindx/src/handles/index.ts +++ b/packages/bindx/src/handles/index.ts @@ -3,7 +3,7 @@ export { FieldHandle } from './FieldHandle.js' export { EntityHandle } from './EntityHandle.js' export { HasOneHandle } from './HasOneHandle.js' export { PlaceholderHandle } from './PlaceholderHandle.js' -export { HasManyListHandle } from './HasManyListHandle.js' +export { HasManyListHandle, HAS_MANY_ALIAS_RESOLVER, type HasManyAliasResolver } from './HasManyListHandle.js' export { FIELD_REF_META, type HasOneRelationState, diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 0d8e4d9a..e49a5e65 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -169,7 +169,7 @@ export { createFragment, buildQueryFromSelection, SELECTION_META, createSelectio export type { HasManyParams, StaticFieldMap, StaticFieldNode, StaticSelectionSchema } from './selection/index.js' // Handles -export { EntityHandle, HasOneHandle, HasManyListHandle, FieldHandle } from './handles/index.js' +export { EntityHandle, HasOneHandle, HasManyListHandle, FieldHandle, HAS_MANY_ALIAS_RESOLVER, type HasManyAliasResolver } from './handles/index.js' // Handle symbols export { FIELD_REF_META } from './handles/index.js' diff --git a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx new file mode 100644 index 00000000..400c3653 --- /dev/null +++ b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx @@ -0,0 +1,244 @@ +// Regression test for https://github.com/contember/bindx/issues/158 +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { act, render, waitFor, cleanup } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter, Entity, Field, HasMany, createComponent, useBindxContext, useSchemaRegistry } from '@contember/bindx-react' +import { EntityHandle, type HasManyAccessor, type QueryFieldSpec, type SchemaRegistry } from '@contember/bindx' +import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../../shared' + +afterEach(() => { + cleanup() +}) + +function recordTagFields(adapter: MockAdapter): { current: QueryFieldSpec[] } { + const query = adapter.query.bind(adapter) + const tagFields: { current: QueryFieldSpec[] } = { current: [] } + adapter.query = (queries, options) => { + for (const entry of queries) { + if (entry.type === 'get') { + tagFields.current = entry.spec.fields.filter(field => field.sourcePath[0] === 'tags') + } + } + return query(queries, options) + } + return tagFields +} + +const nestedFieldNames = (field: QueryFieldSpec | undefined): string[] => field?.nested?.fields.map(nested => nested.name) ?? [] + +describe(' with parameters', () => { + test('should fetch the children fields only under the params alias', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const tagFields = recordTagFields(adapter) + + const { container } = render( + + + {article => ( +
+ + {tag => } + +
+ )} +
+
, + ) + + await waitFor(() => { + expect(queryByTestId(container, 'tags')).not.toBeNull() + }) + + const filtered = tagFields.current.find(field => field.filter !== undefined) + const plain = tagFields.current.find(field => field.name === 'tags') + expect(filtered?.filter).toEqual({ name: { eq: 'React' } }) + expect(nestedFieldNames(filtered)).toContain('name') + // Reading `article.tags` registers the relation with its id only; the children's + // fields are not fetched for the whole relation. + expect(nestedFieldNames(plain)).toEqual(['id']) + }) + + test('should render the filtered list, not the whole relation', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {article => ( +
+ + {tag => } + +
+ )} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toBe('React') + }) + }) + + test('should render each sibling with its own parameters', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {article => ( +
+
+ {tag => } +
+
+ {tag => } +
+
+ {tag => } +
+
+ )} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'all').textContent).toBe('JavaScriptReact') + }) + expect(getByTestId(container, 'react').textContent).toBe('React') + expect(getByTestId(container, 'javascript').textContent).toBe('#f7df1e') + }) + + test('should keep showing items added to the relation in an orderBy-only list', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let tags: HasManyAccessor<{ id: string; name: string; color: string }> | null = null + + const { container } = render( + + + {article => { + tags = article.tags as unknown as typeof tags + return ( +
+ {tag => } +
+ ) + }} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toBe('ReactJavaScript') + }) + + act(() => { + tags!.add({ name: 'Zed', color: '#000' }) + }) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toContain('Zed') + }) + }) + + test('should render the filtered list from a component collected implicitly', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const ReactTags = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + {tag => } + )) + + const { container } = render( + + + {article =>
} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toBe('React') + }) + }) + + test('should render the filtered list for an entity handle without a selection', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + // A row like HasManyDataGrid builds it: the same entity, created without a selection, + // over the store the above filled. + function SelectionlessRow({ id }: { id: string }): React.ReactElement { + const { store, dispatcher } = useBindxContext() + const schemaRegistry = useSchemaRegistry() + const row = EntityHandle.create<{ id: string; tags: { id: string; name: string }[] }>( + id, + 'Article', + store, + dispatcher, + schemaRegistry as SchemaRegistry>, + ) + return ( + {tag => } + ) + } + + const { container } = render( + + + {article => ( +
+ {tag => } +
+
+ )} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'row').textContent).toBe('React') + }) + // The plain relation holds both tags (with only their ids fetched); the filtered list one. + expect(getByTestId(container, 'row').querySelectorAll('i').length).toBe(1) + }) + + test('should show items added in create mode in a filtered or limited list', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let tags: HasManyAccessor<{ id: string; name: string; color: string }> | null = null + + const { container } = render( + + + {article => { + tags = article.tags as unknown as typeof tags + return ( +
+
+ {tag => } +
+
+ {tag => } +
+
+ ) + }} +
+
, + ) + + await waitFor(() => { + expect(queryByTestId(container, 'filtered')).not.toBeNull() + }) + + act(() => { + tags!.add({ name: 'React', color: '#000' }) + }) + + await waitFor(() => { + expect(getByTestId(container, 'filtered').textContent).toBe('React') + }) + expect(getByTestId(container, 'limited').textContent).toBe('React') + }) +}) diff --git a/tests/selectionCollection.test.tsx b/tests/selectionCollection.test.tsx index 7d6f8017..a0146724 100644 --- a/tests/selectionCollection.test.tsx +++ b/tests/selectionCollection.test.tsx @@ -301,8 +301,8 @@ describe('Selection Collection with Schema', () => { // Build query const query = buildQueryFromSelection(selection) - // usages should be in the query - const usagesSpec = query.fields.find((f: any) => f.name === 'usages') + // usages should be in the query, under its orderBy alias + const usagesSpec = query.fields.find((f: any) => f.sourcePath[0] === 'usages' && f.orderBy !== undefined) expect(usagesSpec).toBeDefined() expect(usagesSpec!.nested).toBeDefined()