From ed80d7cc81d051f9dbded6a575f589b421429d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20Krupka?= Date: Mon, 29 Jun 2026 09:25:33 +0200 Subject: [PATCH 1/3] test: failing repro for list/array scalar columns dropped from accessor field types --- tests/typeSafety.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/typeSafety.test.ts b/tests/typeSafety.test.ts index 9a5d29c4..5ecd70c4 100644 --- a/tests/typeSafety.test.ts +++ b/tests/typeSafety.test.ts @@ -19,6 +19,8 @@ import type { EntityFieldsAccessor, EntityFromProp, SelectionFromProp, + ScalarKeys, + FieldAccessor, } from '@contember/bindx-react' import { createFragment, @@ -639,3 +641,39 @@ describe('Type Safety - Integration', () => { assertFalse>() }) }) + +// Regression test for https://github.com/contember/bindx/issues/ +// +// A native list/array scalar column (e.g. a Contember `enumColumn(...).list()` — +// typed as `readonly T[]` where `T` is a string enum, NOT an entity) is dropped +// by every branch of the accessor field-type mapping: +// - `ScalarKeys` excludes it (`T[K] extends (infer _U)[] ? never : …`) +// - `HasManyKeys` excludes it (element is not an object) +// - `HasOneKeys` excludes it (it IS an array) +// so the accessor proxy exposes no `FieldAccessor` for it: `.value` / `.setValue` +// don't exist on the type, even though the runtime `FieldHandle` handles array +// columns fine. Reading or writing such a column from `createComponent` explicit +// selection therefore fails to compile. +interface Lesson { + id: string + title: string + // Native list/array scalar column — array of a string enum, not a relation. + groupSize: readonly ('whole' | 'group' | 'individual')[] +} + +describe('Type Safety - list/array scalar columns', () => { + test('a list scalar column is classified as a scalar key', () => { + // EXPECTED: `groupSize` is a scalar field key (it is a column, just array-valued). + // ACTUAL (bug): `ScalarKeys` is `'id' | 'title'` — `groupSize` is missing. + assertTrue>>() + }) + + test('a list scalar column accessor exposes .value / .setValue', () => { + type LessonAcc = EntityAccessor + type GroupSizeField = LessonAcc['$fields']['groupSize'] + + // EXPECTED: the field is a FieldAccessor carrying the array value. + // ACTUAL (bug): it resolves to a HasOne-style accessor with no `.value`. + assertTrue>>() + }) +}) From b32882b617dc25ca89ed5f9390c2e542467c0d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20Krupka?= Date: Mon, 29 Jun 2026 09:29:31 +0200 Subject: [PATCH 2/3] test: cover selection-builder manifestation of list scalar column bug --- tests/typeSafety.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/typeSafety.test.ts b/tests/typeSafety.test.ts index 5ecd70c4..fa7cf40a 100644 --- a/tests/typeSafety.test.ts +++ b/tests/typeSafety.test.ts @@ -676,4 +676,31 @@ describe('Type Safety - list/array scalar columns', () => { // ACTUAL (bug): it resolves to a HasOne-style accessor with no `.value`. assertTrue>>() }) + + test('a list scalar column is selectable with a zero-arg builder method', () => { + // The same root cause surfaces in the selection builder: `SelectionBuilderMethods` + // routes `TEntity[K] extends Array` to `HasManyMethod`, which requires a + // nested-selection / fragment argument. So `e.groupSize()` (a scalar list column) + // is rejected with "Expected 1-4 arguments, but got 0". + const lessonSchema = defineSchema<{ Lesson: Lesson }>({ + entities: { + Lesson: { + fields: { + id: scalar(), + title: scalar(), + groupSize: scalar(), + }, + }, + }, + }) + void lessonSchema + const LessonDef = entityDef('Lesson') + + // EXPECTED to compile: `groupSize` is a scalar column, selectable with zero args. + // ACTUAL (bug): `e.groupSize()` errors "Expected 1-4 arguments, but got 0". + const Comp = createComponent() + .entity('lesson', LessonDef, e => e.id().groupSize()) + .render(() => null) + void Comp + }) }) From a52c7da587c2e153a1b5c8343027544b4e37702d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 9 Sep 2026 14:32:18 +0200 Subject: [PATCH 3/3] fix(bindx): classify readonly array columns as scalar fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.list()` scalar column is generated as `readonly T[]`, and no `T[K] extends (infer U)[]` test matches a readonly array. Every array-shaped conditional in the accessor field-type mapping and in the selection builder therefore misread such a column: it fell through to the has-one branch, so the accessor exposed no `FieldAccessor` (no `.value` / `.setValue`) and `e.tags()` demanded a nested selection. A readonly has-many (`readonly Target[]`) was misread the same way. Align every site on the `NonNullable extends readonly (infer U)[]` plus `IsPlainObject` idiom that `qb/inputTypes.ts` already used, and deduplicate `IsPlainObject` into `bindx-client/src/utils/fieldShape.ts`. `IsPlainObject` rather than `U extends object` is load-bearing: the latter distributes, so a JSON column whose type includes `readonly JSONValue[]` would be classified as a has-many. `FieldAccessor` is invariant in `T`, so the reporter's assertion against `FieldAccessor` could never hold for a narrowed enum column; it now asserts against the column's own type plus explicit `.value` / `.setValue` shapes. Types only — no runtime behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R --- packages/bindx-client/src/index.ts | 1 + packages/bindx-client/src/qb/inputTypes.ts | 10 +- .../bindx-client/src/selection/queryTypes.ts | 20 +--- packages/bindx-client/src/selection/types.ts | 9 +- packages/bindx-client/src/utils/fieldShape.ts | 16 +++ packages/bindx/src/handles/types.ts | 29 ++--- tests/typeSafety.test.ts | 101 +++++++++++++++--- 7 files changed, 133 insertions(+), 53 deletions(-) create mode 100644 packages/bindx-client/src/utils/fieldShape.ts diff --git a/packages/bindx-client/src/index.ts b/packages/bindx-client/src/index.ts index 66236bee..707ad740 100644 --- a/packages/bindx-client/src/index.ts +++ b/packages/bindx-client/src/index.ts @@ -97,6 +97,7 @@ export type { AnyBrand } from './brand/ComponentBrand.js' // Utils export { generateHasManyAlias } from './utils/aliasGenerator.js' +export type { IsPlainObject } from './utils/fieldShape.js' // Query Builder (static qb module) export * as qb from './qb/index.js' diff --git a/packages/bindx-client/src/qb/inputTypes.ts b/packages/bindx-client/src/qb/inputTypes.ts index 3c39d8c5..2ae71297 100644 --- a/packages/bindx-client/src/qb/inputTypes.ts +++ b/packages/bindx-client/src/qb/inputTypes.ts @@ -6,18 +6,12 @@ * from a separate EntityTypeLike shape. */ +import type { IsPlainObject } from '../utils/fieldShape.js' + // ============================================================================ // Helpers for discriminating field types from entity model // ============================================================================ -/** Detects if T is a plain object (not Date, Function, Array, etc.) */ -type IsPlainObject = - T extends Date ? false - : T extends Function ? false - : T extends readonly unknown[] ? false - : T extends object ? true - : false - /** Extract scalar (non-relation) keys from an entity */ type ScalarKeys = { [K in keyof T]: K extends 'id' ? never diff --git a/packages/bindx-client/src/selection/queryTypes.ts b/packages/bindx-client/src/selection/queryTypes.ts index d44a3ba1..ee8071a2 100644 --- a/packages/bindx-client/src/selection/queryTypes.ts +++ b/packages/bindx-client/src/selection/queryTypes.ts @@ -7,6 +7,7 @@ */ import { Input } from '@contember/schema' +import type { IsPlainObject } from '../utils/fieldShape.js' // ============================================================================ // Re-export Contember types @@ -35,24 +36,13 @@ export interface ComposedWhere { readonly not?: EntityWhere } -/** - * Checks if a type is a "plain object" (entity) vs a scalar like Date - * Date and other built-in objects are not treated as relations - */ -type IsPlainObject = - T extends Date ? false : - T extends Array ? false : - T extends Function ? false : - T extends object ? true : - false - /** * Field-level where clause for an entity * Maps each field to its appropriate condition type using Input.Condition */ export type FieldsWhere = { readonly [K in keyof TEntity]?: - NonNullable extends Array + NonNullable extends readonly (infer U)[] ? IsPlainObject extends true ? EntityWhere | null // has-many: filter on related items (array of entities) : Input.Condition> | null // scalar array: use Input.Condition on the array (supports hasSome, contains, etc.) @@ -76,7 +66,7 @@ export type EntityWhere = ComposedWhere & FieldsWhere */ export type EntityOrderBy = { readonly [K in keyof TEntity]?: - NonNullable extends Array + NonNullable extends readonly unknown[] ? never // has-many cannot be ordered by directly : IsPlainObject> extends true ? EntityOrderBy> | null // has-one: nested ordering @@ -122,12 +112,12 @@ export interface AliasOptions { /** * Extracts the item type from an array type */ -export type ArrayItemType = T extends Array ? U : never +export type ArrayItemType = T extends readonly (infer U)[] ? U : never /** * Checks if a type is an array */ -export type IsArray = T extends Array ? true : false +export type IsArray = T extends readonly unknown[] ? true : false /** * Extracts non-nullable type diff --git a/packages/bindx-client/src/selection/types.ts b/packages/bindx-client/src/selection/types.ts index 788449cf..86ec63a6 100644 --- a/packages/bindx-client/src/selection/types.ts +++ b/packages/bindx-client/src/selection/types.ts @@ -1,5 +1,6 @@ import type { ComponentBrand, AnyBrand } from '../brand/ComponentBrand.js' import type { EntityWhere, EntityOrderBy } from './queryTypes.js' +import type { IsPlainObject } from '../utils/fieldShape.js' /** * Symbol used to store selection metadata on builder objects @@ -60,7 +61,7 @@ export interface SelectionMeta { /** * Extract item type from array */ -type ArrayItemType = T extends Array ? U : never +type ArrayItemType = T extends readonly (infer U)[] ? U : never /** * A fragment defined with the fluent builder @@ -267,8 +268,10 @@ export interface HasManyMethod< * Maps entity fields to their corresponding builder methods */ type SelectionBuilderMethods = { - [K in keyof TEntity]-?: TEntity[K] extends Array - ? HasManyMethod + [K in keyof TEntity]-?: NonNullable extends readonly (infer U)[] + ? IsPlainObject extends true + ? HasManyMethod + : ScalarMethod : NonNullable extends object ? HasOneMethod, TSelected, THasManyParams> : ScalarMethod diff --git a/packages/bindx-client/src/utils/fieldShape.ts b/packages/bindx-client/src/utils/fieldShape.ts new file mode 100644 index 00000000..3afe7812 --- /dev/null +++ b/packages/bindx-client/src/utils/fieldShape.ts @@ -0,0 +1,16 @@ +/** + * Type-level predicates that discriminate entity field shapes. + */ + +/** + * True when T is a plain object (an entity); false for Date, Function, arrays and primitives. + * + * Collapses to `boolean` for a union mixing objects and non-objects (a JSON column), so + * `extends true` rejects it — a bare `T extends object` would distribute and match its object members. + */ +export type IsPlainObject = + T extends Date ? false + : T extends Function ? false + : T extends readonly unknown[] ? false + : T extends object ? true + : false diff --git a/packages/bindx/src/handles/types.ts b/packages/bindx/src/handles/types.ts index 02a1f87f..8c200bac 100644 --- a/packages/bindx/src/handles/types.ts +++ b/packages/bindx/src/handles/types.ts @@ -9,6 +9,7 @@ * - EntityRef / EntityAccessor */ +import type { IsPlainObject } from '@contember/bindx-client' import type { FieldHandle } from './FieldHandle.js' // ============================================================================ @@ -51,8 +52,10 @@ export type { UnsubscribeType as Unsubscribe } // ============================================================================ export type ScalarKeys = { - [K in keyof T]: T[K] extends (infer _U)[] - ? never + [K in keyof T]: NonNullable extends readonly (infer U)[] + ? IsPlainObject extends true + ? never + : K : NonNullable extends object ? K extends 'id' ? K @@ -61,15 +64,15 @@ export type ScalarKeys = { }[keyof T] export type HasManyKeys = { - [K in keyof T]: T[K] extends (infer U)[] - ? U extends object + [K in keyof T]: NonNullable extends readonly (infer U)[] + ? IsPlainObject extends true ? K : never : never }[keyof T] export type HasOneKeys = { - [K in keyof T]: T[K] extends (infer _U)[] + [K in keyof T]: NonNullable extends readonly (infer _U)[] ? never : NonNullable extends object ? K extends 'id' @@ -413,8 +416,8 @@ export type EntityAccessorLike = EntityRefLike & { export type EntityFields = { [K in ScalarKeys]: FieldHandle } & { - [K in HasManyKeys]: T[K] extends (infer U)[] - ? U extends object + [K in HasManyKeys]: NonNullable extends readonly (infer U)[] + ? IsPlainObject extends true ? HasManyAccessor : never : never @@ -427,9 +430,9 @@ export type EntityFields = { */ type FieldRefType, K extends keyof TEntity & keyof TSelected> = K extends ScalarKeys ? FieldRef : - K extends HasManyKeys ? (TEntity[K] extends (infer U)[] - ? U extends object - ? HasManyRef extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType, TSchema> + K extends HasManyKeys ? (NonNullable extends readonly (infer U)[] + ? IsPlainObject extends true + ? HasManyRef extends readonly (infer S)[] ? S : U, AnyBrand, EntityNameFromType, TSchema> : never : never) : K extends HasOneKeys ? HasOneRef< @@ -446,9 +449,9 @@ type FieldRefType, K */ type FieldAccessorType, K extends keyof TEntity & keyof TSelected> = K extends ScalarKeys ? FieldAccessor : - K extends HasManyKeys ? (TEntity[K] extends (infer U)[] - ? U extends object - ? HasManyAccessor extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType, TSchema> + K extends HasManyKeys ? (NonNullable extends readonly (infer U)[] + ? IsPlainObject extends true + ? HasManyAccessor extends readonly (infer S)[] ? S : U, AnyBrand, EntityNameFromType, TSchema> : never : never) : K extends HasOneKeys ? HasOneAccessor< diff --git a/tests/typeSafety.test.ts b/tests/typeSafety.test.ts index fa7cf40a..e31dd9d7 100644 --- a/tests/typeSafety.test.ts +++ b/tests/typeSafety.test.ts @@ -20,7 +20,10 @@ import type { EntityFromProp, SelectionFromProp, ScalarKeys, + HasManyKeys, + HasOneKeys, FieldAccessor, + HasManyAccessor, } from '@contember/bindx-react' import { createFragment, @@ -642,18 +645,16 @@ describe('Type Safety - Integration', () => { }) }) -// Regression test for https://github.com/contember/bindx/issues/ +// Regression test for https://github.com/contember/bindx/issues/58 // // A native list/array scalar column (e.g. a Contember `enumColumn(...).list()` — -// typed as `readonly T[]` where `T` is a string enum, NOT an entity) is dropped -// by every branch of the accessor field-type mapping: -// - `ScalarKeys` excludes it (`T[K] extends (infer _U)[] ? never : …`) -// - `HasManyKeys` excludes it (element is not an object) -// - `HasOneKeys` excludes it (it IS an array) -// so the accessor proxy exposes no `FieldAccessor` for it: `.value` / `.setValue` -// don't exist on the type, even though the runtime `FieldHandle` handles array -// columns fine. Reading or writing such a column from `createComponent` explicit -// selection therefore fails to compile. +// typed as `readonly T[]` where `T` is a string enum, NOT an entity) is misclassified +// by the accessor field-type mapping: the key-set helpers test `T[K] extends (infer U)[]`, +// which no readonly array matches, so a list column falls through to the has-one branch. +// The accessor proxy then exposes no `FieldAccessor` for it: `.value` / `.setValue` don't +// exist on the type, even though the runtime `FieldHandle` handles array columns fine. +// Reading or writing such a column from `createComponent` explicit selection therefore +// fails to compile. interface Lesson { id: string title: string @@ -661,6 +662,30 @@ interface Lesson { groupSize: readonly ('whole' | 'group' | 'individual')[] } +type JsonValue = string | number | boolean | null | readonly JsonValue[] | { readonly [key: string]: JsonValue } + +interface Chapter { + id: string + name: string +} + +interface Course { + id: string + // Mutable list scalar column. + weekdays: number[] + // Nullable list scalar column. + tags: readonly string[] | null + // JSON column whose type mixes objects and primitives. + metadata: JsonValue + published: boolean | null + // Mutable and readonly has-many relations. + chapters: Chapter[] + archivedChapters: readonly Chapter[] + // Nullable and non-nullable has-one relations. + author: Chapter | null + owner: Chapter +} + describe('Type Safety - list/array scalar columns', () => { test('a list scalar column is classified as a scalar key', () => { // EXPECTED: `groupSize` is a scalar field key (it is a column, just array-valued). @@ -674,14 +699,19 @@ describe('Type Safety - list/array scalar columns', () => { // EXPECTED: the field is a FieldAccessor carrying the array value. // ACTUAL (bug): it resolves to a HasOne-style accessor with no `.value`. - assertTrue>>() + // `FieldAccessor` is invariant in `T` (`inputProps.setValue` is a function-typed + // property), so the accessor is compared against the column's own type. + assertTrue>>() + assertTrue>() + assertTrue void>>() }) test('a list scalar column is selectable with a zero-arg builder method', () => { // The same root cause surfaces in the selection builder: `SelectionBuilderMethods` - // routes `TEntity[K] extends Array` to `HasManyMethod`, which requires a - // nested-selection / fragment argument. So `e.groupSize()` (a scalar list column) - // is rejected with "Expected 1-4 arguments, but got 0". + // routes `TEntity[K] extends Array` to a relation method, which requires a + // nested-selection / fragment argument. A readonly list column misses that test and + // lands on `HasOneMethod`, so `e.groupSize()` (zero args) is rejected with + // "Expected 1-4 arguments, but got 0". const lessonSchema = defineSchema<{ Lesson: Lesson }>({ entities: { Lesson: { @@ -703,4 +733,47 @@ describe('Type Safety - list/array scalar columns', () => { .render(() => null) void Comp }) + + test('array-shaped columns are scalar keys regardless of mutability or nullability', () => { + assertTrue>>() + assertTrue>>() + assertTrue>>() + assertTrue>>() + assertTrue>>() + }) + + test('a list scalar column is neither a has-one nor a has-many key', () => { + assertFalse>>() + assertFalse>>() + assertFalse>>() + assertFalse>>() + assertFalse>>() + assertFalse>>() + // A JSON column's type includes `readonly JsonValue[]`; `U extends object` would + // distribute over that union and misread the column as a has-many. + assertFalse>>() + assertFalse>>() + }) + + test('a readonly has-many relation is still a has-many key', () => { + assertTrue>>() + assertTrue>>() + assertFalse>>() + assertFalse>>() + assertFalse>>() + }) + + test('has-one relations keep their classification', () => { + assertTrue>>() + assertTrue>>() + assertFalse>>() + assertFalse>>() + }) + + test('a readonly has-many relation resolves to a HasManyAccessor', () => { + type CourseAcc = EntityAccessor + type ArchivedField = CourseAcc['$fields']['archivedChapters'] + + assertTrue>>() + }) })