diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 54efff7..53c1983 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -192,7 +192,8 @@ function HasManyDataGridImpl({ return } - const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data })) + store.indexServerResponse(targetEntityType, relation.rows, setup.selection, schemaRegistry) + const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data: store.resolveServerOccurrence(data) })) store.batchNotifications(() => { for (const item of items) { dispatcher.dispatch(setEntityData(targetEntityType, item.id, item.data, true)) @@ -211,7 +212,7 @@ function HasManyDataGridImpl({ return () => { abortController.abort() } - }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store]) + }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store, schemaRegistry]) // ---- Build items from state ---- const items = useMemo((): EntityAccessor[] => { diff --git a/packages/bindx-react/src/hooks/useEntity.ts b/packages/bindx-react/src/hooks/useEntity.ts index 0e12393..5246381 100644 --- a/packages/bindx-react/src/hooks/useEntity.ts +++ b/packages/bindx-react/src/hooks/useEntity.ts @@ -292,7 +292,8 @@ export function useEntity( if (result.type === 'get' && result.data === null) { dispatcher.dispatch(setLoadState(entityType, id, 'not_found')) } else if (result.type === 'get' && result.data) { - const data = result.data + store.indexServerResponse(entityType, result.data, selectionMeta, schemaRegistry) + const data = store.resolveServerOccurrence(result.data) // Revalidation: advance the server baseline but keep local dirty // edits intact (see EntitySnapshotStore.refreshServerData). store.batchNotifications(() => { @@ -323,7 +324,7 @@ export function useEntity( fetchingRef.current = null } } - }, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta]) + }, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta, schemaRegistry]) // --- EntityHandle --- // The handle keeps a stable identity across data changes — it is a stateless live view over the diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index e9409fc..1148d52 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -473,7 +473,8 @@ export function useEntityList( throw new Error('Unexpected query result type') } - const items = result.data.map(data => ({ id: getRowId(entityType, data), data })) + store.indexServerResponse(entityType, result.data, selectionMeta, schemaRegistry) + const items = result.data.map(data => ({ id: getRowId(entityType, data), data: store.resolveServerOccurrence(data) })) store.batchNotifications(() => { for (const item of items) { // Revalidation preserves local edits while advancing the server baseline. @@ -504,7 +505,7 @@ export function useEntityList( return () => { abortController.abort() } - }, [entityType, optionsKey, requestKey, batcher, dispatcher, store, selectionMeta]) + }, [entityType, optionsKey, requestKey, batcher, dispatcher, store, selectionMeta, schemaRegistry]) return accessor } diff --git a/packages/bindx/src/core/EntityLoader.ts b/packages/bindx/src/core/EntityLoader.ts index 7c758fa..5c163aa 100644 --- a/packages/bindx/src/core/EntityLoader.ts +++ b/packages/bindx/src/core/EntityLoader.ts @@ -60,6 +60,13 @@ export interface LoadEntityListOptions { /** * Non-React service for loading entities. * Can be used in any JavaScript environment. + * + * It writes each response as it arrives, without uniting the occurrences of one + * entity within it (see `SnapshotStore.indexServerResponse`): that needs the + * selection and the schema, and the loader has only a `QuerySpec`. A query that + * reaches one entity through two paths with different sub-selections can + * therefore lose the wider one's fields, as described in + * https://github.com/contember/bindx/issues/123. The React hooks do not go through it. */ export class EntityLoader { constructor( diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index a74a4db..f0e9ef9 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -284,7 +284,8 @@ export class HasManyListHandle>): void { - for (const itemData of listData) { + for (const embeddedItem of listData) { + const itemData = this.store.resolveServerOccurrence(embeddedItem) const itemId = itemData['id'] as string if (!itemId) continue diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index 596000e..91dbd0f 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -418,11 +418,13 @@ export class HasOneHandle return } + const occurrence = this.store.resolveServerOccurrence(embeddedData as Record) + // Skip if embedded data values match existing serverData — avoids overwriting // unpersisted local mutations when a re-fetch returns the same server data // (e.g. polling). A new reference with identical values means no actual change. const existing = this.store.getEntitySnapshot(this.targetType, id) - if (existing?.serverData && embeddedDataMatchesSnapshot(embeddedData as Record, existing.serverData as Record)) { + if (existing?.serverData && embeddedDataMatchesSnapshot(occurrence, existing.serverData as Record)) { this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData) return } @@ -435,7 +437,7 @@ export class HasOneHandle this.store.refreshServerData( this.targetType, id, - embeddedData as Record, + occurrence, true, // skipNotify - called during render, data already exists embedded in parent ) this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData) diff --git a/packages/bindx/src/store/ResponseOccurrenceIndex.ts b/packages/bindx/src/store/ResponseOccurrenceIndex.ts new file mode 100644 index 0000000..3052716 --- /dev/null +++ b/packages/bindx/src/store/ResponseOccurrenceIndex.ts @@ -0,0 +1,224 @@ +import type { SelectionMeta } from '../selection/types.js' + +/** The schema knowledge the index needs: where a relation points and whether it is a list. */ +export interface RelationTargetResolver { + getRelationTarget(entityType: string, fieldName: string): string | undefined + isHasMany(entityType: string, fieldName: string): boolean +} + +type EntityRecord = Record + +interface PendingVisit { + readonly value: unknown + readonly entityType: string + readonly selection: SelectionMeta + readonly isList: boolean +} + +/** + * Unites the occurrences of one entity within a single server response. + * + * One query can reach the same entity through several paths, each with its own + * sub-selection (`article.attachments` next to `article.sections.article.attachments`). + * Every occurrence is later written into the entity's snapshot, so without this + * the narrower one could replace fields the wider one carried. Occurrences of + * one response are equally fresh, so they are merged without any recency rule: + * each maps to the union of all of them, keyed by entity type and id. A scalar two + * occurrences disagree on (which one server read should not produce) takes the + * value of the occurrence visited last. The response is walked breadth first, + * relations in selection order and list items in list order, so the outcome + * follows from the response and the selection alone. + * + * The union is shallow. A relation value in it is one occurrence's raw value, and + * the related entities inside it resolve to their own unions when they are + * written, so relations inside relations unite level by level without rewriting + * the response. + * + * Reads never merge across responses: a later read replaces what an earlier one + * stored, as before. + */ +export class ResponseOccurrenceIndex { + private readonly unions = new WeakMap>() + + /** + * Indexes a response read for `entityType` with `selection`. The type of every + * nested occurrence comes from the schema through the selection's field names; + * an occurrence whose type cannot be resolved is left out rather than matched + * by id alone. + * + * Occurrences of one type at one selection node carry the same fields, so + * their union changes nothing. Only types the selection reaches at two or more + * nodes are collected, only the relations leading to them are walked, and a + * selection with none skips the walk entirely (see {@link planOccurrenceWalk}). + */ + index(entityType: string, data: unknown, selection: SelectionMeta, schema: RelationTargetResolver): void { + const plan = planOccurrenceWalk(entityType, selection, schema) + if (plan.repeatedTypes.size === 0) { + return + } + const root: PendingVisit = { value: data, entityType, selection, isList: Array.isArray(data) } + const occurrencesByType = collectOccurrences(root, plan, schema) + for (const occurrencesById of occurrencesByType.values()) { + for (const occurrences of occurrencesById.values()) { + if (Array.isArray(occurrences)) { + this.unite(occurrences) + } + } + } + } + + private unite(occurrences: readonly EntityRecord[]): void { + const union: EntityRecord = {} + for (const occurrence of occurrences) { + Object.assign(union, occurrence) + } + Object.freeze(union) + for (const occurrence of occurrences) { + this.unions.set(occurrence, union) + } + } + + /** The union of every occurrence of this entity in its response, or the occurrence itself when it had no sibling. */ + resolve(occurrence: EntityRecord): EntityRecord { + return this.unions.get(occurrence) ?? occurrence + } +} + +/** Occurrences by entity type and id. A lone occurrence is kept as is; an array only appears once an id repeats. */ +type OccurrencesByType = Map> + +function collectOccurrences(root: PendingVisit, plan: OccurrenceWalkPlan, schema: RelationTargetResolver): OccurrencesByType { + const occurrencesByType: OccurrencesByType = new Map() + const pending: PendingVisit[] = [root] + for (let next = 0; next < pending.length; next++) { + const visit = pending[next]! + for (const entity of entitiesOf(visit.value, visit.isList)) { + if (plan.repeatedTypes.has(visit.entityType)) { + addOccurrence(occurrencesByType, visit.entityType, entity) + } + for (const fieldMeta of visit.selection.fields.values()) { + if (!fieldMeta.nested || !plan.nodesToWalk.has(fieldMeta.nested) || entity[fieldMeta.alias] == null) continue + const targetType = schema.getRelationTarget(visit.entityType, fieldMeta.fieldName) + if (targetType === undefined) continue + pending.push({ + value: entity[fieldMeta.alias], + entityType: targetType, + selection: fieldMeta.nested, + isList: schema.isHasMany(visit.entityType, fieldMeta.fieldName), + }) + } + } + } + return occurrencesByType +} + +/** What walking a response needs from its selection. */ +export interface OccurrenceWalkPlan { + /** Entity types the selection reaches at two or more nodes, such as `article` and `article.sections.article`. Only these are united. */ + readonly repeatedTypes: ReadonlySet + /** Selection nodes on a path to a repeated type. The walk descends into no other. */ + readonly nodesToWalk: ReadonlySet +} + +/** + * Plans the walk of one response. It depends only on the schema, the selection + * and its root type, and costs one pass over the selection tree, not over the + * response. It is computed per response rather than cached, because a selection + * can still grow after its first fetch (`mergeSelections` extends nested + * selections in place, and a fragment's selection is shared by every place + * that uses it). + */ +export function planOccurrenceWalk(entityType: string, selection: SelectionMeta, schema: RelationTargetResolver): OccurrenceWalkPlan { + const nodes = listSelectionNodes(entityType, selection, schema) + const repeatedTypes = findRepeatedTypes(nodes) + return { repeatedTypes, nodesToWalk: findNodesToWalk(nodes, repeatedTypes) } +} + +interface SelectionNode { + readonly entityType: string + readonly selection: SelectionMeta + readonly parent: number | null +} + +/** Every position in the selection tree, breadth first, each with the index of its parent. */ +function listSelectionNodes(entityType: string, selection: SelectionMeta, schema: RelationTargetResolver): readonly SelectionNode[] { + const nodes: SelectionNode[] = [{ entityType, selection, parent: null }] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]! + for (const fieldMeta of node.selection.fields.values()) { + if (!fieldMeta.isRelation || !fieldMeta.nested) continue + const targetType = schema.getRelationTarget(node.entityType, fieldMeta.fieldName) + if (targetType !== undefined) { + nodes.push({ entityType: targetType, selection: fieldMeta.nested, parent: index }) + } + } + } + return nodes +} + +function findRepeatedTypes(nodes: readonly SelectionNode[]): ReadonlySet { + const seenTypes = new Set() + const repeatedTypes = new Set() + for (const node of nodes) { + if (seenTypes.has(node.entityType)) { + repeatedTypes.add(node.entityType) + } + seenTypes.add(node.entityType) + } + return repeatedTypes +} + +/** + * The selections on a path from the root to a repeated type. The climb tracks + * tree positions, not selection objects: a reused fragment is one selection + * object at several positions, and each position has ancestors of its own. + */ +function findNodesToWalk(nodes: readonly SelectionNode[], repeatedTypes: ReadonlySet): ReadonlySet { + const nodesToWalk = new Set() + const climbed = new Set() + nodes.forEach((node, index) => { + if (!repeatedTypes.has(node.entityType)) return + for (let position: number | null = index; position !== null && !climbed.has(position); position = nodes[position]!.parent) { + climbed.add(position) + nodesToWalk.add(nodes[position]!.selection) + } + }) + return nodesToWalk +} + +function addOccurrence(occurrencesByType: OccurrencesByType, entityType: string, entity: EntityRecord): void { + const id = entity['id'] + if (typeof id !== 'string' && typeof id !== 'number') return + let occurrencesById = occurrencesByType.get(entityType) + if (!occurrencesById) { + occurrencesById = new Map() + occurrencesByType.set(entityType, occurrencesById) + } + const known = occurrencesById.get(id) + if (known === undefined) { + occurrencesById.set(id, entity) + } else if (Array.isArray(known)) { + known.push(entity) + } else if (known !== entity) { + occurrencesById.set(id, [known, entity]) + } +} + +/** The entity records a relation value holds: a has-one object, or a has-many array or connection (`{ edges: [{ node }] }`). */ +function entitiesOf(value: unknown, isList: boolean): readonly EntityRecord[] { + if (Array.isArray(value)) { + return value.filter(isRecord) + } + if (!isRecord(value)) { + return [] + } + if (!isList) { + return [value] + } + const edges = value['edges'] + return Array.isArray(edges) ? edges.map(edge => (isRecord(edge) ? edge['node'] : undefined)).filter(isRecord) : [] +} + +function isRecord(value: unknown): value is EntityRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 3c756de..aa4d956 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1,6 +1,7 @@ import type { EntitySnapshot, LoadStatus } from './snapshots.js' import { createEntitySnapshot } from './snapshots.js' import type { FieldError, FieldErrorFilter } from '../errors/types.js' +import type { SelectionMeta } from '../selection/types.js' import { SubscriptionManager, type SnapshotVersionBumper, type SynchronousResult } from './SubscriptionManager.js' import { ErrorStore } from './ErrorStore.js' import { @@ -21,6 +22,7 @@ import { generateTempId } from './entityId.js' import { DirtyTracker } from './DirtyTracker.js' import { EntitySnapshotStore } from './EntitySnapshotStore.js' import { RootRegistry } from './RootRegistry.js' +import { ResponseOccurrenceIndex, type RelationTargetResolver } from './ResponseOccurrenceIndex.js' import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js' import { RekeyOrchestrator } from './RekeyOrchestrator.js' import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' @@ -92,6 +94,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { */ private readonly lastPropagatedData = new Map() + private readonly responseOccurrences = new ResponseOccurrenceIndex() + /** * Optional write-journal. When set (by an attached UndoManager), mutating * methods record editable-layer pre-images of the cells they touch so a gesture @@ -318,6 +322,23 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { return newSnapshot as EntitySnapshot } + /** + * Unites the occurrences of each entity within one server response, so the + * narrower of two occurrences cannot replace fields the wider one carried. + * Call it before any of the response is written. See {@link ResponseOccurrenceIndex}. + */ + indexServerResponse(entityType: string, data: unknown, selection: SelectionMeta, schema: RelationTargetResolver): void { + this.responseOccurrences.index(entityType, data, selection, schema) + } + + /** + * What to write for an entity occurrence of a server response: the union of + * its occurrences in that response, or the occurrence itself. + */ + resolveServerOccurrence(occurrence: Record): Record { + return this.responseOccurrences.resolve(occurrence) + } + /** * Refreshes server data from a revalidation read while preserving the user's * local dirty edits. See {@link EntitySnapshotStore.refreshServerData}. diff --git a/tests/react/relations/hasMany/nestedSameEntityNarrowerSelection.test.tsx b/tests/react/relations/hasMany/nestedSameEntityNarrowerSelection.test.tsx new file mode 100644 index 0000000..a66ac8d --- /dev/null +++ b/tests/react/relations/hasMany/nestedSameEntityNarrowerSelection.test.tsx @@ -0,0 +1,449 @@ +// Regression test for https://github.com/contember/bindx/issues/123 +import '../../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + createFragment, + defineSchema, + entityDef, + hasMany, + hasOne, + MockAdapter, + scalar, + type SnapshotStore, + useEntity, + useEntityList, + usePersist, + useSnapshotStore, +} from '@contember/bindx-react' +import { getByTestId, queryByTestId } from './setup' + +afterEach(() => { + cleanup() +}) + +interface StoredFile { + id: string + url: string + size: number +} + +interface Attachment { + id: string + name: string + type: string + file: StoredFile | null +} + +interface Company { + id: string + address: string +} + +interface Badge { + id: string + label: string +} + +interface Author { + id: string + name: string + email: string + company: Company | null + badges: Badge[] +} + +interface Section { + id: string + article: Article | null + reviewer: Author | null +} + +interface Article { + id: string + attachments: Attachment[] + sections: Section[] + author: Author | null + editor: Author | null +} + +interface CycleSchema { + Article: Article + Section: Section + Attachment: Attachment + Author: Author + StoredFile: StoredFile + Company: Company + Badge: Badge +} + +const schema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + attachments: hasMany('Attachment'), + sections: hasMany('Section'), + author: hasOne('Author', { nullable: true }), + editor: hasOne('Author', { nullable: true }), + }, + }, + Section: { + fields: { + id: scalar(), + article: hasOne('Article', { nullable: true }), + reviewer: hasOne('Author', { nullable: true }), + }, + }, + Attachment: { + fields: { + id: scalar(), + name: scalar(), + type: scalar(), + file: hasOne('StoredFile', { nullable: true }), + }, + }, + Author: { + fields: { + id: scalar(), + name: scalar(), + email: scalar(), + company: hasOne('Company', { nullable: true }), + badges: hasMany('Badge'), + }, + }, + Company: { + fields: { + id: scalar(), + address: scalar(), + }, + }, + Badge: { + fields: { + id: scalar(), + label: scalar(), + }, + }, + StoredFile: { + fields: { + id: scalar(), + url: scalar(), + size: scalar(), + }, + }, + }, +}) + +const entityDefs = { + Article: entityDef
('Article'), + Attachment: entityDef('Attachment'), +} as const + +const by = { by: { id: 'article-1' } } + +interface MockOptions { + readonly type?: string + readonly authorId?: string +} + +function createMockData({ type = 'pdf', authorId = 'author-1' }: MockOptions = {}): ConstructorParameters[0] { + const file = { id: 'file-1', url: '/slides.pdf', size: 42 } + const attachment = { id: 'att-1', name: 'Slides', type, file } + const author = { + id: authorId, + name: 'Ann', + email: 'ann@example.com', + company: { id: 'company-1', address: 'Main St' }, + badges: [{ id: 'badge-1', label: 'Maintainer' }], + } + const article = { id: 'article-1', attachments: [attachment], author, editor: author, sections: [] as unknown[] } + // The section points back at the SAME article, and at the same person as its author: one response reaches both twice. + article.sections = [{ id: 'section-1', article, reviewer: author }] + return { + Article: { 'article-1': article }, + Section: {}, + Attachment: { 'att-1': { ...attachment } }, + Author: { [authorId]: { ...author } }, + StoredFile: {}, + Company: {}, + Badge: {}, + } +} + +function renderWithin(adapter: MockAdapter, children: React.ReactNode): ReturnType { + return render({children}) +} + +async function waitForTestIds(container: HTMLElement, ...testIds: string[]): Promise { + await waitFor(() => { + for (const testId of testIds) { + expect(queryByTestId(container, testId)).not.toBeNull() + } + }) +} + +let bindxStore: SnapshotStore | null = null +let persistAll: (() => Promise) | null = null +let editArticle: ((type: string, email: string) => void) | null = null + +/** What the store holds for an entity field, as data and as its server baseline. */ +function storedField(entityType: string, id: string, field: string): string { + const snapshot = bindxStore!.getEntitySnapshot>(entityType, id) + return `${String(snapshot?.data[field])}/${String(snapshot?.serverData[field])}` +} + +function hasStoredField(entityType: string, id: string, field: string): boolean { + const snapshot = bindxStore!.getEntitySnapshot>(entityType, id) + return snapshot !== undefined && Object.hasOwn(snapshot.data, field) +} + +/** + * One root whose selection reaches the same article twice: directly with + * `attachments { name type file { url size } }` and `author { name email }`, and through + * `sections.article` with `attachments { name file { url } }` and `author { name }`. + * `readNestedFirst` mirrors two sibling components where the one holding the narrower + * selection renders first. + */ +function ArticleView({ readNestedFirst }: { readNestedFirst: boolean }): React.ReactElement { + bindxStore = useSnapshotStore() + const persist = usePersist() + persistAll = () => persist.persistAll() + const article = useEntity(entityDefs.Article, by, e => + e.id() + .attachments(a => a.id().name().type().file(f => f.id().url().size())) + .author(a => a.id().name().email()) + .sections(s => s.id().article(r => r.id().attachments(a => a.id().name().file(f => f.id().url())).author(a => a.id().name()))), + ) + + if (article.$isLoading || article.$isError || article.$isNotFound) { + return
Loading...
+ } + + editArticle = (type, email) => { + article.attachments.items[0]?.$fields.type.setValue(type) + article.author.email.setValue(email) + } + + const nestedNames = (): string => article.sections.items + .flatMap(s => [...s.article.attachments.items.map(a => `${a.$fields.name.value}@${a.file.url.value}`), s.article.author.name.value]) + .join(',') + const direct = (): string => article.attachments.items + .map(a => `${String(a.$fields.type.value)}:${String(a.file.size.value)}`) + .concat(String(article.author.email.value)) + .join(',') + + const nestedBefore = readNestedFirst ? nestedNames() : '' + const directValues = direct() + const nested = readNestedFirst ? nestedBefore : nestedNames() + + return ( +
+ {nested} + {directValues} +
+ ) +} + +function ArticleList({ readNestedFirst }: { readNestedFirst: boolean }): React.ReactElement { + bindxStore = useSnapshotStore() + const articles = useEntityList(entityDefs.Article, {}, e => + e.id() + .attachments(a => a.id().name().type()) + .sections(s => s.id().article(r => r.id().attachments(a => a.id().name()))), + ) + if (articles.$status !== 'ready') { + return
Loading...
+ } + const nested = (): string => articles.items + .flatMap(it => it.sections.items.flatMap(s => s.article.attachments.items.map(a => a.$fields.name.value))) + .join(',') + const direct = (): string => articles.items.flatMap(it => it.attachments.items.map(a => String(a.$fields.type.value))).join(',') + const nestedBefore = readNestedFirst ? nested() : '' + const directValues = direct() + return ( +
+ {readNestedFirst ? nestedBefore : nested()} + {directValues} +
+ ) +} + +/** Both occurrences select the has-many with the same params, so they share one aliased, paginated key. */ +function PaginatedView({ readNestedFirst }: { readNestedFirst: boolean }): React.ReactElement { + const params = { orderBy: [{ name: 'asc' as const }], limit: 10 } + const article = useEntity(entityDefs.Article, by, e => + e.id() + .attachments(params, a => a.id().name().type()) + .sections(s => s.id().article(r => r.id().attachments(params, a => a.id().name()))), + ) + if (article.$isLoading || article.$isError || article.$isNotFound) { + return
Loading...
+ } + const nested = (): string => article.sections.items.flatMap(s => s.article.attachments.items.map(a => a.$fields.name.value)).join(',') + const nestedBefore = readNestedFirst ? nested() : '' + const direct = article.attachments.items.map(a => String(a.$fields.type.value)).join(',') + return ( +
+ {readNestedFirst ? nestedBefore : nested()} + {direct} +
+ ) +} + +const PersonFragment = createFragment()(a => a.id().name().company(c => c.id()).badges(b => b.id())) + +/** + * One person reached three times: through the same fragment as the article's author and as a + * section's reviewer (a has-many away), and as the editor with a wider has-one and has-many. + * `readNestedFirst` reads the fragment positions before the editor; otherwise the editor is + * touched first, the fragment positions next, and the editor's relations last. + */ +function SharedFragmentView({ readNestedFirst }: { readNestedFirst: boolean }): React.ReactElement { + const article = useEntity(entityDefs.Article, by, e => + e.id() + .author(PersonFragment) + .editor(p => p.id().company(c => c.id().address()).badges(b => b.id().label())) + .sections(s => s.id().reviewer(PersonFragment)), + ) + if (article.$isLoading || article.$isError || article.$isNotFound) { + return
Loading...
+ } + const fragmentNames = (): string => [article.author.name.value, ...article.sections.items.map(s => s.reviewer.name.value)].join(',') + const editorWide = (): string => [article.editor.company.address.value, ...article.editor.badges.items.map(b => b.$fields.label.value)].join(',') + + const editorId = readNestedFirst ? null : article.editor.$id + const names = fragmentNames() + const wide = editorWide() + return ( +
+ {names} + {wide} +
+ ) +} + +function NarrowRoot(): React.ReactElement { + const article = useEntity(entityDefs.Article, by, e => e.id().attachments(a => a.id().name()).author(a => a.id().name())) + if (article.$isLoading || article.$isError || article.$isNotFound) { + return
Loading...
+ } + return {article.attachments.items.map(a => a.$fields.name.value).join(',')} +} + +function AttachmentRoot(): React.ReactElement { + const attachment = useEntity(entityDefs.Attachment, { by: { id: 'att-1' } }, e => e.id().type()) + if (attachment.$isLoading || attachment.$isError || attachment.$isNotFound) { + return
Loading...
+ } + return {String(attachment.type.value)} +} + +for (const readNestedFirst of [true, false]) { + const order = readNestedFirst ? 'narrower nested occurrence read first' : 'wider direct occurrence read first' + + describe(`one response reaching the same entity twice (${order})`, () => { + test('keeps the wider has-many, has-one and nested has-one fields', async () => { + const { container } = renderWithin(new MockAdapter(createMockData(), { delay: 0 }), ) + await waitForTestIds(container, 'direct') + + expect(getByTestId(container, 'nested').textContent).toBe('Slides@/slides.pdf,Ann') + expect(getByTestId(container, 'direct').textContent).toBe('pdf:42,ann@example.com') + expect(storedField('Attachment', 'att-1', 'type')).toBe('pdf/pdf') + expect(storedField('StoredFile', 'file-1', 'size')).toBe('42/42') + }) + + test('keeps the wider fields across the items of a list response', async () => { + const { container } = renderWithin(new MockAdapter(createMockData(), { delay: 0 }), ) + await waitForTestIds(container, 'direct') + + expect(getByTestId(container, 'nested').textContent).toBe('Slides') + expect(getByTestId(container, 'direct').textContent).toBe('pdf') + }) + + test('keeps the wider fields of a paginated has-many selected with params', async () => { + const { container } = renderWithin(new MockAdapter(createMockData(), { delay: 0 }), ) + await waitForTestIds(container, 'direct') + + expect(getByTestId(container, 'nested').textContent).toBe('Slides') + expect(getByTestId(container, 'direct').textContent).toBe('pdf') + }) + + test('keeps the wider has-one and has-many fields of an entity also selected through a reused fragment', async () => { + const { container } = renderWithin(new MockAdapter(createMockData(), { delay: 0 }), ) + await waitForTestIds(container, 'direct') + + expect(getByTestId(container, 'nested').textContent).toBe('Ann,Ann') + expect(getByTestId(container, 'direct').textContent).toBe('Main St,Maintainer') + }) + + test('keeps two entity types that share an id apart', async () => { + const adapter = new MockAdapter(createMockData({ authorId: 'att-1' }), { delay: 0 }) + const { container } = renderWithin(adapter, ) + await waitForTestIds(container, 'direct') + + expect(getByTestId(container, 'direct').textContent).toBe('pdf:42,ann@example.com') + expect(hasStoredField('Attachment', 'att-1', 'email')).toBe(false) + expect(hasStoredField('Author', 'att-1', 'type')).toBe(false) + }) + }) + + describe(`separate reads behave as before (${order})`, () => { + test('a narrower read after a persist keeps the persisted values', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { container, rerender } = renderWithin(adapter, ) + await waitForTestIds(container, 'direct') + + act(() => editArticle!('docx', 'ann@example.org')) + await act(async () => { + await persistAll!() + }) + + rerender() + await waitForTestIds(container, 'narrow') + + expect(storedField('Attachment', 'att-1', 'type')).toBe('docx/docx') + expect(storedField('Author', 'author-1', 'email')).toBe('ann@example.org/ann@example.org') + expect(getByTestId(container, 'direct').textContent).toBe('docx:42,ann@example.org') + }) + + test('a refetch after a server change shows the new value', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { container, rerender } = renderWithin(adapter, ) + await waitForTestIds(container, 'direct') + expect(getByTestId(container, 'direct').textContent).toBe('pdf:42,ann@example.com') + + adapter.resetStore(createMockData({ type: 'docx' })) + rerender() + + await waitFor(() => { + expect(getByTestId(container, 'direct').textContent).toBe('docx:42,ann@example.com') + }) + expect(storedField('Attachment', 'att-1', 'type')).toBe('docx/docx') + }) + + test('a fresher read through another root is not overwritten by an older read', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { container, rerender } = renderWithin(adapter, ) + await waitForTestIds(container, 'direct') + + adapter.resetStore(createMockData({ type: 'docx' })) + rerender() + await waitForTestIds(container, 'attachment-type') + + rerender( + + + + + , + ) + await waitForTestIds(container, 'narrow') + + expect(storedField('Attachment', 'att-1', 'type')).toBe('docx/docx') + expect(getByTestId(container, 'attachment-type').textContent).toBe('docx') + expect(getByTestId(container, 'direct').textContent).toBe('docx:42,ann@example.com') + }) + }) +} diff --git a/tests/unit/store/responseOccurrenceIndex.test.ts b/tests/unit/store/responseOccurrenceIndex.test.ts new file mode 100644 index 0000000..1d9fe37 --- /dev/null +++ b/tests/unit/store/responseOccurrenceIndex.test.ts @@ -0,0 +1,271 @@ +// Regression tests for https://github.com/contember/bindx/issues/123 +import { describe, expect, test } from 'bun:test' +import { __internal, createFragment } from '@contember/bindx-react' +import { planOccurrenceWalk, ResponseOccurrenceIndex, type RelationTargetResolver } from '../../../packages/bindx/src/store/ResponseOccurrenceIndex.js' + +const { createSelectionBuilder, getSelectionMeta } = __internal + +interface Tag { + id: string + name: string + color: string +} + +interface Author { + id: string + name: string + email: string + tags: Tag[] +} + +interface Section { + id: string + article: Article | null + reviewer: Author | null +} + +interface Article { + id: string + title: string + author: Author | null + coAuthor: Author | null + tags: Tag[] + sections: Section[] +} + +const relations: Record> = { + Article: { + author: { target: 'Author', isHasMany: false }, + coAuthor: { target: 'Author', isHasMany: false }, + tags: { target: 'Tag', isHasMany: true }, + sections: { target: 'Section', isHasMany: true }, + }, + Section: { + article: { target: 'Article', isHasMany: false }, + reviewer: { target: 'Author', isHasMany: false }, + }, + Author: { + tags: { target: 'Tag', isHasMany: true }, + }, +} + +const schema: RelationTargetResolver = { + getRelationTarget: (entityType, fieldName) => relations[entityType]?.[fieldName]?.target, + isHasMany: (entityType, fieldName) => relations[entityType]?.[fieldName]?.isHasMany ?? false, +} + +describe('ResponseOccurrenceIndex', () => { + test('resolves every occurrence of an entity to the union of their fields', () => { + const selection = getSelectionMeta( + createSelectionBuilder
().id().author(a => a.id().name()).coAuthor(a => a.id().email()), + ) + const author = { id: 'author-1', name: 'Ann' } + const coAuthor = { id: 'author-1', email: 'ann@example.com' } + const index = new ResponseOccurrenceIndex() + + index.index('Article', { id: 'article-1', author, coAuthor }, selection, schema) + + const expected = { id: 'author-1', name: 'Ann', email: 'ann@example.com' } + expect(index.resolve(author)).toEqual(expected) + expect(index.resolve(coAuthor)).toBe(index.resolve(author)) + }) + + test('returns an occurrence with no sibling as is', () => { + const selection = getSelectionMeta(createSelectionBuilder
().id().author(a => a.id().name())) + const author = { id: 'author-1', name: 'Ann' } + const index = new ResponseOccurrenceIndex() + + index.index('Article', { id: 'article-1', author }, selection, schema) + + expect(index.resolve(author)).toBe(author) + }) + + test('unites relations inside relations level by level', () => { + const selection = getSelectionMeta( + createSelectionBuilder
() + .id() + .author(a => a.id().tags(t => t.id().name())) + .coAuthor(a => a.id().tags(t => t.id().color())), + ) + const narrowTag = { id: 'tag-1', name: 'News' } + const otherTag = { id: 'tag-1', color: 'red' } + const index = new ResponseOccurrenceIndex() + + index.index('Article', { + id: 'article-1', + author: { id: 'author-1', tags: [narrowTag] }, + coAuthor: { id: 'author-1', tags: [otherTag] }, + }, selection, schema) + + expect(index.resolve(narrowTag)).toEqual({ id: 'tag-1', name: 'News', color: 'red' }) + }) + + test('unites the nodes of a paginated connection by id', () => { + const selection = getSelectionMeta( + createSelectionBuilder
().id().tags(t => t.id().name()).author(a => a.id().tags(t => t.id().color())), + ) + const node = { id: 'tag-1', name: 'News' } + const otherNode = { id: 'tag-1', color: 'red' } + const index = new ResponseOccurrenceIndex() + + index.index('Article', { + id: 'article-1', + tags: { pageInfo: { totalCount: 1 }, edges: [{ node }] }, + author: { id: 'author-1', tags: { pageInfo: { totalCount: 1 }, edges: [{ node: otherNode }] } }, + }, selection, schema) + + expect(index.resolve(node)).toEqual({ id: 'tag-1', name: 'News', color: 'red' }) + }) + + test('keeps entity types that share an id apart', () => { + const selection = getSelectionMeta( + createSelectionBuilder
() + .id() + .author(a => a.id().name().tags(t => t.id().name())) + .coAuthor(a => a.id().email()) + .tags(t => t.id().color()), + ) + const author = { id: 'shared', name: 'Ann', tags: [{ id: 'tag-1', name: 'News' }] } + const tag = { id: 'shared', color: 'red' } + const index = new ResponseOccurrenceIndex() + + index.index('Article', { id: 'article-1', author, coAuthor: { id: 'author-2', email: 'bob@example.com' }, tags: [tag] }, selection, schema) + + expect(index.resolve(author)).toBe(author) + expect(index.resolve(tag)).toBe(tag) + }) + + test('leaves out a relation whose target type the schema does not know', () => { + const selection = getSelectionMeta(createSelectionBuilder
().id().author(a => a.id().name()).coAuthor(a => a.id().email())) + const author = { id: 'author-1', name: 'Ann' } + const coAuthor = { id: 'author-1', email: 'ann@example.com' } + const index = new ResponseOccurrenceIndex() + const withoutCoAuthor: RelationTargetResolver = { + getRelationTarget: (entityType, fieldName) => (fieldName === 'coAuthor' ? undefined : schema.getRelationTarget(entityType, fieldName)), + isHasMany: schema.isHasMany, + } + + index.index('Article', { id: 'article-1', author, coAuthor }, selection, withoutCoAuthor) + + expect(index.resolve(author)).toBe(author) + expect(index.resolve(coAuthor)).toBe(coAuthor) + }) + + test('takes a scalar the occurrences disagree on from the one visited last', () => { + const selection = getSelectionMeta(createSelectionBuilder
().id().author(a => a.id().name()).coAuthor(a => a.id().name())) + const author = { id: 'author-1', name: 'Ann' } + const index = new ResponseOccurrenceIndex() + + index.index('Article', { id: 'article-1', author, coAuthor: { id: 'author-1', name: 'Anna' } }, selection, schema) + + // Breadth first, relations in selection order: `coAuthor` is visited after `author`. + expect(index.resolve(author)['name']).toBe('Anna') + }) + + test('indexes the items of a list response together', () => { + const selection = getSelectionMeta(createSelectionBuilder
().id().author(a => a.id().name()).coAuthor(a => a.id().email())) + const author = { id: 'author-1', name: 'Ann' } + const index = new ResponseOccurrenceIndex() + + index.index('Article', [ + { id: 'article-1', author }, + { id: 'article-2', coAuthor: { id: 'author-1', email: 'ann@example.com' } }, + ], selection, schema) + + expect(index.resolve(author)).toEqual({ id: 'author-1', name: 'Ann', email: 'ann@example.com' }) + }) + + describe('fast path', () => { + const countingSchema = (): { resolver: RelationTargetResolver; calls: () => number } => { + let calls = 0 + return { + resolver: { + getRelationTarget: (entityType, fieldName) => { + calls++ + return schema.getRelationTarget(entityType, fieldName) + }, + isHasMany: schema.isHasMany, + }, + calls: () => calls, + } + } + const row = (i: number): { article: Record; nestedArticle: Record } => { + const nestedArticle = { id: `article-${i}`, author: { id: 'author-1', name: 'Ann' } } + const article = { id: `article-${i}`, author: { id: 'author-1', name: 'Ann' }, sections: [{ id: `section-${i}`, article: nestedArticle }] } + return { article, nestedArticle } + } + const rows = (count: number): Record[] => Array.from({ length: count }, (_, i) => row(i).article) + + test('finds no repeated type in a selection that reaches every type once', () => { + const selection = getSelectionMeta(createSelectionBuilder
().id().author(a => a.id().name()).tags(t => t.id().name())) + + expect([...planOccurrenceWalk('Article', selection, schema).repeatedTypes]).toEqual([]) + }) + + test('finds the type a cycle reaches twice', () => { + const selection = getSelectionMeta( + createSelectionBuilder
().id().tags(t => t.id().name()).sections(s => s.id().article(a => a.id().tags(t => t.id().color()))), + ) + const plan = planOccurrenceWalk('Article', selection, schema) + + expect([...plan.repeatedTypes].sort()).toEqual(['Article', 'Tag']) + }) + + test('walks only the relations that lead to a repeated type', () => { + const selection = getSelectionMeta( + createSelectionBuilder
().id().author(a => a.id().name()).sections(s => s.id().article(a => a.id().title())), + ) + const plan = planOccurrenceWalk('Article', selection, schema) + + expect([...plan.repeatedTypes]).toEqual(['Article']) + expect(plan.nodesToWalk.has(selection)).toBe(true) + expect(plan.nodesToWalk.has(selection.fields.get('sections')!.nested!)).toBe(true) + expect(plan.nodesToWalk.has(selection.fields.get('author')!.nested!)).toBe(false) + }) + + test('skips walking a response whose selection repeats no type', () => { + const selection = getSelectionMeta(createSelectionBuilder
().id().author(a => a.id().name())) + const callsFor = (rowCount: number): number => { + const { resolver, calls } = countingSchema() + new ResponseOccurrenceIndex().index('Article', rows(rowCount), selection, resolver) + return calls() + } + + // Only the plan consults the schema, once per selection position; the rows are never walked. + expect(callsFor(1000)).toBe(callsFor(10)) + expect(callsFor(10)).toBeLessThan(10) + }) + + test('walks the ancestors of every position a reused fragment takes', () => { + const PersonFragment = createFragment()(a => a.id().name()) + const selection = getSelectionMeta( + createSelectionBuilder
().id().author(PersonFragment).sections(s => s.id().reviewer(PersonFragment)), + ) + const plan = planOccurrenceWalk('Article', selection, schema) + + expect([...plan.repeatedTypes]).toEqual(['Author']) + expect(plan.nodesToWalk.has(selection.fields.get('sections')!.nested!)).toBe(true) + + const reviewer = { id: 'author-1', name: 'Ann' } + const author = { id: 'author-1', name: 'Ann' } + const index = new ResponseOccurrenceIndex() + index.index('Article', { id: 'article-1', author, sections: [{ id: 'section-1', reviewer }] }, selection, schema) + + expect(index.resolve(reviewer)).toBe(index.resolve(author)) + }) + + test('walks a response whose selection reaches a type twice', () => { + const selection = getSelectionMeta( + createSelectionBuilder
().id().author(a => a.id().name()).sections(s => s.id().article(a => a.id().author(u => u.id().name()))), + ) + const { resolver, calls } = countingSchema() + const index = new ResponseOccurrenceIndex() + const first = row(0) + + index.index('Article', [first.article, ...rows(100).slice(1)], selection, resolver) + + expect(calls()).toBeGreaterThan(100) + expect(index.resolve(first.nestedArticle)).toBe(index.resolve(first.article)) + }) + }) +})