Skip to content
Open
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 docs/compiler-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Entity> root with a parameterised <HasMany> bails (HASMANY_PARAMS_AT_ROOT).
// Has-many params are not part of the format: a parameterised <HasMany> is collected and
// rendered under its params alias, so the component or <Entity> root holding it bails (HASMANY_PARAMS).

/** Emitted as the 2nd argument of .render(): key = implicit entity prop name. */
type StaticSelection = Record<string, StaticFieldMap>
Expand Down
2 changes: 1 addition & 1 deletion docs/selection-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 46 additions & 9 deletions packages/bindx-client/src/selection/SelectionScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/bindx-compiler/src/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function analyzeChain(
return { loc, bailout: chain.earlyBail }
}
const propRoots = new Map<string, SelNode>(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)
Expand Down
3 changes: 1 addition & 2 deletions packages/bindx-compiler/src/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/bindx-compiler/src/entityRoots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 7 additions & 6 deletions packages/bindx-compiler/src/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ export class JsxAnalyzer {
private readonly moduleScope: ModuleScope,
private readonly contracts: ContractLookup,
private readonly targetKinds: TargetKindLookup,
/** Analyzing an `<Entity>` root: the runtime walk there also keeps what components return. */
private readonly isSelectionRoot: boolean,
) {}

walk(node: t.JSXElement | t.JSXFragment, scope: Scope): void {
Expand Down Expand Up @@ -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 <HasMany>'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: '<HasMany> with params in an <Entity> 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 <HasMany> 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: '<HasMany> with params' })
}
const item = many ? consumeMany(res.ref) : consumeRelation(res.ref)
const children = childrenSlot(node)
Expand Down
2 changes: 1 addition & 1 deletion packages/bindx-compiler/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
26 changes: 10 additions & 16 deletions packages/bindx-compiler/tests/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,10 @@ function selectionOf(code: string): Record<string, unknown> {
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 <HasMany> 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', () => {
Expand Down Expand Up @@ -88,9 +77,14 @@ describe('bail triggers (direct)', () => {
expect(bail(`({ article }) => <Field field={article[key]} />`)).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 }) => <HasMany field={article.tags} {...listParams}>{t => <Field field={t.name} />}</HasMany>`))
.toBe('HASMANY_PARAMS')
})

test('HasMany params inside a component', () => {
expect(bail(`({ article }) => <HasMany field={article.tags} limit={n}>{t => <Field field={t.name} />}</HasMany>`))
.toBeNull()
.toBe('HASMANY_PARAMS')
})

test('HasMany params in an <Entity> root', () => {
Expand All @@ -100,7 +94,7 @@ describe('bail triggers (direct)', () => {
+ `<HasMany field={a.tags} limit={1}>{t => <Field field={t.name} />}</HasMany>}</Entity>`,
'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')
})
})

Expand Down
7 changes: 3 additions & 4 deletions packages/bindx-compiler/tests/fixtures/hasManyParams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
Expand All @@ -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' },
]
3 changes: 1 addition & 2 deletions packages/bindx-compiler/tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const Card = createComponent()
<div>
<Field field={article.title} />
<HasOne field={article.author}>{a => <Field field={a.name} />}</HasOne>
<HasMany field={article.tags} limit={5}>{tag => <Field field={tag.name} />}</HasMany>
<HasMany field={article.tags}>{tag => <Field field={tag.name} />}</HasMany>
</div>
))
`
Expand All @@ -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 <HasMany> params inside a component
// This chain has no holes → the holes key is omitted.
expect(output).not.toContain('holes:')
})
Expand Down
8 changes: 7 additions & 1 deletion packages/bindx-react/src/jsx/collectorProxy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'

Expand Down Expand Up @@ -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<unknown>(scope, targetEntityName, schemaRegistry), scope }
},

// FieldRef properties (non-$ versions)
value: null,
Expand Down
62 changes: 47 additions & 15 deletions packages/bindx-react/src/jsx/components/HasMany.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -36,9 +44,19 @@ function HasManyImpl<
TBrand extends AnyBrand = AnyBrand,
TEntityName extends string = string,
TSchema extends Record<string, object> = Record<string, object>,
>({ field, children }: HasManyProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): ReactElement {
>({ field, children, filter, orderBy, limit, offset }: HasManyProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): 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<TEntity, TSelected, TBrand, TEntityName, TSchema>
: 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) => {
Expand Down Expand Up @@ -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<unknown>

// 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<symbol, unknown>)[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<symbol, unknown>)[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() }
Expand Down
Loading
Loading