From 2ce0ca6422f13160b10de66ebb3ff9befe8548db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20Krupka?= Date: Fri, 15 May 2026 15:26:24 +0200 Subject: [PATCH 1/3] test: failing repro for fulltext query filter not registrable on HasOne nested text fields --- .../dataGridFulltextNestedFields.test.tsx | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/react/dataview/dataGridFulltextNestedFields.test.tsx diff --git a/tests/react/dataview/dataGridFulltextNestedFields.test.tsx b/tests/react/dataview/dataGridFulltextNestedFields.test.tsx new file mode 100644 index 0000000..2860511 --- /dev/null +++ b/tests/react/dataview/dataGridFulltextNestedFields.test.tsx @@ -0,0 +1,204 @@ +// Regression test for +// +// `` only renders the toolbar search input when at +// least one column registers `isTextSearchable: true` AND a `fieldName`. +// `DataGridTextColumn` is the only built-in that sets `isTextSearchable`, +// and it extracts `fieldName` from the FieldRef's `FIELD_REF_META.fieldName` +// — which is just the last accessed segment (`"name"`), not a dotted path +// (`"author.name"`). +// +// As a result, when the grid's entity has NO direct scalar text fields and +// the searchable text data lives only inside HasOne relations, there is no +// public API to register a `"author.name"`-style path into the auto-built +// `createFullTextFilterHandler(textFieldPaths)`. The query filter handler +// is not created → toolbar search input never appears. +// +// `createFullTextFilterHandler` itself DOES support dotted paths internally +// (it calls `buildNestedWhere` for each path), so this is a missing +// composition API in the bindx-ui/bindx-dataview surface, not a fundamental +// limitation of the filter handler. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + defineSchema, + hasOne, + MockAdapter, + scalar, +} from '@contember/bindx-react' +import { entityDef } from '@contember/bindx' +import { + DataGrid, + DataGridHasOneColumn, + DataGridTextColumn, + QUERY_FILTER_NAME, + useDataViewContext, +} from '@contember/bindx-dataview' + +afterEach(() => { + cleanup() +}) + +// ============================================================================ +// Schema — Article has only HasOne author, no direct text scalars +// ============================================================================ + +interface Author { + id: string + name: string + email: string +} + +interface Article { + id: string + author: Author | null +} + +interface TestSchema { + Article: Article + Author: Author +} + +const localSchema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + author: hasOne('Author', { nullable: true }), + }, + }, + Author: { + fields: { + id: scalar(), + name: scalar(), + email: scalar(), + }, + }, + }, +}) + +const localEntityDefs = { + Article: entityDef
('Article'), + Author: entityDef('Author'), +} as const + +function createMockData(): Record>> { + return { + Article: { + a1: { id: 'a1', author: { id: 'auth-1', name: 'John', email: 'john@example.com' } }, + a2: { id: 'a2', author: { id: 'auth-2', name: 'Jane', email: 'jane@example.com' } }, + }, + Author: { + 'auth-1': { id: 'auth-1', name: 'John', email: 'john@example.com' }, + 'auth-2': { id: 'auth-2', name: 'Jane', email: 'jane@example.com' }, + }, + } +} + +interface FilterState { + registered: readonly string[] + hasQueryFilter: boolean +} + +// ============================================================================ +// Failing repro +// ============================================================================ + +describe('DataGrid fulltext across HasOne nested fields', () => { + test('public API cannot register a nested HasOne text path for the toolbar query filter', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let captured: FilterState | null = null + + function FilterProbe(): React.ReactElement | null { + const { filtering } = useDataViewContext() + captured = { + registered: Array.from(filtering.filters.keys()), + hasQueryFilter: filtering.filters.has(QUERY_FILTER_NAME), + } + return null + } + + // Render a grid where the only displayable content is the HasOne + // author relation. No direct text scalars on Article. + render( + + + {it => ( + <> + {/* Nested access workaround attempt: pass `it.author.name` to a text + * column. The collector proxy returns a FieldRef whose `fieldName` + * is just `"name"`, so the registered text-searchable path is + * `"name"` — wrong entity. The query filter handler ends up looking + * for `Article.name` which doesn't exist. */} + + + {author => author.name.value} + + + + )} + + , + ) + + await waitFor(() => { + expect(captured).not.toBeNull() + }) + + // The toolbar query filter IS registered (a text column was found), but it + // targets the wrong path — `"name"` on Article, not `"author.name"`. + // Demonstrate the gap by asserting that a properly nested path was the + // one registered. This assertion is what we'd want to hold once the API + // supports it; today it fails. + const filters = Array.from(captured!.registered) + expect(filters).toContain(QUERY_FILTER_NAME) + + // The full-text handler exposes its target paths via `toWhere` — + // resolve them by activating the filter and inspecting the where clause. + // Read the actual handler from the DataView context: + const _adapter = adapter // keep reference alive for re-render + let capturedWhere: Record | undefined + function WhereProbe(): React.ReactElement | null { + const { filtering } = useDataViewContext() + const handler = filtering.filters.get(QUERY_FILTER_NAME)?.handler + if (handler) { + capturedWhere = handler.toWhere({ mode: 'contains', query: 'John' } as never) + } + return null + } + + cleanup() // re-render with the WhereProbe in place + render( + + + {it => ( + <> + + + {author => author.name.value} + + + + )} + + , + ) + + await waitFor(() => { + expect(capturedWhere).toBeDefined() + }) + + // We WANT: { author: { name: { containsCI: 'John' } } } — full-text + // search across `author.name`. + // We GET: { name: { containsCI: 'John' } } — a where clause on a + // non-existent `Article.name` field, because the collector proxy + // stripped the parent context off the FieldRef. + // + // This assertion documents the desired behavior. It fails today. + expect(capturedWhere).toEqual({ + author: { name: { containsCI: 'John' } }, + }) + }) +}) From 25d612a60e5ac5d597ddc63fc4fc43cc48e9ffb1 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 1 Jun 2026 15:01:40 +0200 Subject: [PATCH 2/3] fix(dataview): track full dotted path for nested HasOne column fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataGrid columns whose searchable/sortable text lives inside a HasOne relation (e.g. ``) only registered the last path segment (`"name"`), so the auto-built `createFullTextFilterHandler` produced a where clause against a non-existent `Article.name` field instead of `{ author: { name: ... } }`. The collector proxy now records an absolute `fullPath` on each field ref's `FIELD_REF_META`, threaded through has-one nesting. `fieldName` / `path` stay relative (last segment) so selection and relation metadata — which drive query/selection building via SelectionScope — are unchanged. The DataGrid column layer reads the dotted `fullPath` via `extractFieldName`, and `accessField` now traverses dotted paths so nested cell value extraction works too. Filter/sort handlers already split on `.` (buildNestedWhere / buildNestedOrderBy), so the dotted path flows straight through. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/bindx-dataview/src/columnTypes.ts | 19 +++++++++++--- packages/bindx-dataview/src/columns.tsx | 16 +++++++++--- .../bindx-react/src/jsx/collectorProxy.ts | 26 ++++++++++++++----- packages/bindx/src/handles/types.ts | 10 +++++++ 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/bindx-dataview/src/columnTypes.ts b/packages/bindx-dataview/src/columnTypes.ts index dd432bc..2ce1c0f 100644 --- a/packages/bindx-dataview/src/columnTypes.ts +++ b/packages/bindx-dataview/src/columnTypes.ts @@ -52,12 +52,25 @@ export function defineColumnType // ============================================================================ /** - * Access a field on an EntityAccessor by name. + * Access a field on an EntityAccessor by name or dotted path. * EntityAccessor is a Proxy — bracket notation triggers the proxy get trap. + * Dotted paths (e.g. `"author.name"`) are traversed through has-one relations: + * each intermediate segment resolves to a related EntityAccessor, and the final + * segment to the leaf field ref. */ export function accessField(accessor: EntityAccessor, fieldName: string): unknown { - // EntityAccessor is a Proxy — bracket access goes through the get trap - return (accessor as unknown as Record)[fieldName] + if (!fieldName.includes('.')) { + // EntityAccessor is a Proxy — bracket access goes through the get trap + return (accessor as unknown as Record)[fieldName] + } + + const segments = fieldName.split('.') + let current: unknown = accessor + for (let i = 0; i < segments.length; i++) { + if (current == null || typeof current !== 'object') return null + current = (current as Record)[segments[i]!] + } + return current } function extractScalarValue(accessor: EntityAccessor, fieldName: string): T | null { diff --git a/packages/bindx-dataview/src/columns.tsx b/packages/bindx-dataview/src/columns.tsx index 2ef4c46..ac0d4c5 100644 --- a/packages/bindx-dataview/src/columns.tsx +++ b/packages/bindx-dataview/src/columns.tsx @@ -45,6 +45,7 @@ interface FieldRefMetaCarrier { readonly [FIELD_REF_META]: { readonly entityType: string readonly fieldName: string + readonly fullPath?: readonly string[] readonly isArray: boolean readonly isRelation: boolean readonly enumName?: string @@ -56,9 +57,18 @@ export function hasFieldRefMeta(ref: unknown): ref is FieldRefMetaCarrier { return ref != null && typeof ref === 'object' && FIELD_REF_META in ref } -/** Extract field name from a field ref (works in both collector and runtime proxies). */ +/** + * Extract the dotted field path from a field ref (works in both collector and + * runtime proxies). For fields reached through has-one relations + * (e.g. `it.author.name`) this is the full dotted path (`"author.name"`) so the + * DataGrid can build correct nested where/orderBy clauses; for top-level fields + * it is simply the field name (`"title"`). + */ export function extractFieldName(ref: unknown): string | null { - return hasFieldRefMeta(ref) ? ref[FIELD_REF_META].fieldName : null + if (!hasFieldRefMeta(ref)) return null + const meta = ref[FIELD_REF_META] + const fullPath = meta.fullPath + return fullPath && fullPath.length > 0 ? fullPath.join('.') : meta.fieldName } /** Extract enum name from a field ref (if field is an enum). */ @@ -303,7 +313,7 @@ export const DataGridColumn = Object.assign( header, renderCell: (accessor: EntityAccessor) => { if (!fieldName) return null - const ref = (accessor as unknown as Record)[fieldName] + const ref = accessField(accessor, fieldName) const value = ref && typeof ref === 'object' && 'value' in ref ? (ref as { value: unknown }).value ?? null : null diff --git a/packages/bindx-react/src/jsx/collectorProxy.ts b/packages/bindx-react/src/jsx/collectorProxy.ts index 786febe..153ed11 100644 --- a/packages/bindx-react/src/jsx/collectorProxy.ts +++ b/packages/bindx-react/src/jsx/collectorProxy.ts @@ -33,13 +33,14 @@ export function createCollectorProxy( scope: SelectionScope, entityName: string | null = null, schemaRegistry: SchemaRegistry> | null = null, + parentPath: readonly string[] = [], ): EntityAccessor { const fieldsProxy = new Proxy({} as EntityFields, { get(_, fieldName: string): FieldAccessor | HasManyAccessor | HasOneAccessor { // Return a collector ref that works for all field types // The actual type (scalar/hasMany/hasOne) will be determined // by how it's used in components or by schema lookup - return createCollectorFieldRef(scope, fieldName, entityName, schemaRegistry) + return createCollectorFieldRef(scope, fieldName, entityName, schemaRegistry, parentPath) }, }) @@ -97,6 +98,7 @@ function createCollectorFieldRef( fieldName: string, entityName: string | null, schemaRegistry: SchemaRegistry> | null, + parentPath: readonly string[] = [], ): CollectorRef { // Look up field type from schema if available const fieldDef = entityName && schemaRegistry @@ -132,11 +134,17 @@ function createCollectorFieldRef( return childScope } + // Absolute path from the root collector to this field. `fieldName` / `path` + // stay relative (last segment only) so selection/relation metadata is + // unaffected; `fullPath` carries the dotted chain for DataGrid columns. + const fullPath = [...parentPath, fieldName] + const meta = { entityType: targetEntityName ?? '', // Collection phase - entity type from schema entityId: '', // Collection phase - no entity path: [fieldName], fieldName, + fullPath, isArray: isHasManyRelation, isRelation: isHasOneRelation || isHasManyRelation, enumName, @@ -149,8 +157,10 @@ function createCollectorFieldRef( get(_, nestedFieldName: string) { // Get child scope (upgrades to relation) const scope = getChildScope() - // Create nested field ref in the child scope, passing target entity info - return createCollectorFieldRef(scope, nestedFieldName, targetEntityName, schemaRegistry) + // Create nested field ref in the child scope, passing target entity info. + // Thread the current field's fullPath so e.g. `it.author.name` carries + // the dotted absolute path `['author', 'name']`. + return createCollectorFieldRef(scope, nestedFieldName, targetEntityName, schemaRegistry, fullPath) }, }) @@ -158,7 +168,9 @@ function createCollectorFieldRef( // Get child scope and mark as array relation const scope = getChildScope() parentScope.markAsArray(fieldName) - // Call fn once with collector to gather nested selection, passing target entity info + // Call fn once with collector to gather nested selection, passing target entity info. + // Items of a has-many start a fresh path: dotted where/sort paths only + // make sense across has-one chains, not across collection items. fn(createCollectorProxy(scope, targetEntityName, schemaRegistry), 0) return [] } @@ -211,9 +223,11 @@ function createCollectorFieldRef( $isDirty: false, $fields: hasOneFieldsProxy, get $entity(): EntityAccessor { - // Get child scope (upgrades to relation) and return proxy with scope + // Get child scope (upgrades to relation) and return proxy with scope. + // Thread fullPath for has-one so nested fields keep their dotted chain; + // has-many items start fresh (handled via getById/map). const scope = getChildScope() - return createCollectorProxy(scope, targetEntityName, schemaRegistry) + return createCollectorProxy(scope, targetEntityName, schemaRegistry, isHasManyRelation ? [] : fullPath) }, $delete: () => {}, $remove: () => {}, diff --git a/packages/bindx/src/handles/types.ts b/packages/bindx/src/handles/types.ts index 58ff3d2..819d760 100644 --- a/packages/bindx/src/handles/types.ts +++ b/packages/bindx/src/handles/types.ts @@ -98,6 +98,16 @@ export interface FieldRefMeta { readonly enumName?: string readonly columnType?: string readonly targetType?: string + /** + * Absolute dot-path of segments from the root entity to this field. + * For a top-level field this is `[fieldName]`; for a field reached through + * relations (e.g. `it.author.name`) it is the full chain + * (`['author', 'name']`). `fieldName` / `path` remain the *last* segment + * (relative to the field's own scope) so selection/relation metadata is + * unaffected — consumers that need the full dotted path (DataGrid column + * filter/sort/value extraction) read `fullPath`. + */ + readonly fullPath?: readonly string[] } export interface InputProps { From d1d54ccccdef4731c1f35cb84839842f8eb0bcd6 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 1 Jun 2026 15:55:15 +0200 Subject: [PATCH 3/3] test: reword nested-HasOne fulltext test as a passing regression guard The repro comments/title still described the pre-fix failing state. Update them to reflect that the fix threads the full dotted path, so the test now documents the guaranteed behavior rather than a known bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dataGridFulltextNestedFields.test.tsx | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/tests/react/dataview/dataGridFulltextNestedFields.test.tsx b/tests/react/dataview/dataGridFulltextNestedFields.test.tsx index 2860511..5178a35 100644 --- a/tests/react/dataview/dataGridFulltextNestedFields.test.tsx +++ b/tests/react/dataview/dataGridFulltextNestedFields.test.tsx @@ -1,22 +1,21 @@ -// Regression test for +// Regression guard for fulltext query filters over HasOne nested text fields. // // `` only renders the toolbar search input when at // least one column registers `isTextSearchable: true` AND a `fieldName`. -// `DataGridTextColumn` is the only built-in that sets `isTextSearchable`, -// and it extracts `fieldName` from the FieldRef's `FIELD_REF_META.fieldName` -// — which is just the last accessed segment (`"name"`), not a dotted path -// (`"author.name"`). +// `DataGridTextColumn` is the only built-in that sets `isTextSearchable`, and +// it extracts the field path from the FieldRef's `FIELD_REF_META`. // -// As a result, when the grid's entity has NO direct scalar text fields and -// the searchable text data lives only inside HasOne relations, there is no -// public API to register a `"author.name"`-style path into the auto-built -// `createFullTextFilterHandler(textFieldPaths)`. The query filter handler -// is not created → toolbar search input never appears. +// Previously that path was just the last accessed segment (`"name"`), not the +// dotted chain (`"author.name"`): a text column bound to a HasOne field like +// `it.author.name` registered the wrong path (`Article.name`), so the auto-built +// `createFullTextFilterHandler(textFieldPaths)` produced a where clause against +// a non-existent field. Now the collector proxy threads the absolute `fullPath`, +// so the registered path is the full dotted chain and the where clause nests +// correctly through the relation. // -// `createFullTextFilterHandler` itself DOES support dotted paths internally -// (it calls `buildNestedWhere` for each path), so this is a missing -// composition API in the bindx-ui/bindx-dataview surface, not a fundamental -// limitation of the filter handler. +// `createFullTextFilterHandler` already supports dotted paths internally (it +// calls `buildNestedWhere` for each path); this test guards the composition +// across the bindx-dataview column surface. import '../../setup' import { afterEach, describe, expect, test } from 'bun:test' import { cleanup, render, waitFor } from '@testing-library/react' @@ -103,11 +102,11 @@ interface FilterState { } // ============================================================================ -// Failing repro +// Regression guard // ============================================================================ describe('DataGrid fulltext across HasOne nested fields', () => { - test('public API cannot register a nested HasOne text path for the toolbar query filter', async () => { + test('registers the full nested HasOne text path for the toolbar query filter', async () => { const adapter = new MockAdapter(createMockData(), { delay: 0 }) let captured: FilterState | null = null @@ -127,11 +126,11 @@ describe('DataGrid fulltext across HasOne nested fields', () => { {it => ( <> - {/* Nested access workaround attempt: pass `it.author.name` to a text - * column. The collector proxy returns a FieldRef whose `fieldName` - * is just `"name"`, so the registered text-searchable path is - * `"name"` — wrong entity. The query filter handler ends up looking - * for `Article.name` which doesn't exist. */} + {/* Pass `it.author.name` (a field reached through the HasOne + * relation) to a text column. The collector proxy threads the + * absolute `fullPath`, so the registered text-searchable path is + * the dotted chain `"author.name"` and the query filter handler + * builds a where clause nested under the relation. */} {author => author.name.value} @@ -147,11 +146,9 @@ describe('DataGrid fulltext across HasOne nested fields', () => { expect(captured).not.toBeNull() }) - // The toolbar query filter IS registered (a text column was found), but it - // targets the wrong path — `"name"` on Article, not `"author.name"`. - // Demonstrate the gap by asserting that a properly nested path was the - // one registered. This assertion is what we'd want to hold once the API - // supports it; today it fails. + // The toolbar query filter is registered (a text column was found) and + // targets the correct nested path `"author.name"` (asserted via the + // resulting where clause below). const filters = Array.from(captured!.registered) expect(filters).toContain(QUERY_FILTER_NAME) @@ -190,13 +187,10 @@ describe('DataGrid fulltext across HasOne nested fields', () => { expect(capturedWhere).toBeDefined() }) - // We WANT: { author: { name: { containsCI: 'John' } } } — full-text - // search across `author.name`. - // We GET: { name: { containsCI: 'John' } } — a where clause on a - // non-existent `Article.name` field, because the collector proxy - // stripped the parent context off the FieldRef. - // - // This assertion documents the desired behavior. It fails today. + // Expect { author: { name: { containsCI: 'John' } } } — full-text search + // nested through `author.name`. The collector proxy preserves the parent + // context on the FieldRef via `fullPath`, so the where clause nests under + // the relation instead of targeting a non-existent `Article.name` field. expect(capturedWhere).toEqual({ author: { name: { containsCI: 'John' } }, })