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 9a5d29c4..e31dd9d7 100644 --- a/tests/typeSafety.test.ts +++ b/tests/typeSafety.test.ts @@ -19,6 +19,11 @@ import type { EntityFieldsAccessor, EntityFromProp, SelectionFromProp, + ScalarKeys, + HasManyKeys, + HasOneKeys, + FieldAccessor, + HasManyAccessor, } from '@contember/bindx-react' import { createFragment, @@ -639,3 +644,136 @@ describe('Type Safety - Integration', () => { assertFalse>() }) }) + +// 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 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 + // Native list/array scalar column — array of a string enum, not a relation. + 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). + // 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`. + // `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 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: { + 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 + }) + + 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>>() + }) +})