Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bindx-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 2 additions & 8 deletions packages/bindx-client/src/qb/inputTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> =
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<T> = {
[K in keyof T]: K extends 'id' ? never
Expand Down
20 changes: 5 additions & 15 deletions packages/bindx-client/src/selection/queryTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { Input } from '@contember/schema'
import type { IsPlainObject } from '../utils/fieldShape.js'

// ============================================================================
// Re-export Contember types
Expand Down Expand Up @@ -35,24 +36,13 @@ export interface ComposedWhere<TEntity> {
readonly not?: EntityWhere<TEntity>
}

/**
* 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> =
T extends Date ? false :
T extends Array<any> ? 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<TEntity> = {
readonly [K in keyof TEntity]?:
NonNullable<TEntity[K]> extends Array<infer U>
NonNullable<TEntity[K]> extends readonly (infer U)[]
? IsPlainObject<U> extends true
? EntityWhere<U> | null // has-many: filter on related items (array of entities)
: Input.Condition<NonNullable<TEntity[K]>> | null // scalar array: use Input.Condition on the array (supports hasSome, contains, etc.)
Expand All @@ -76,7 +66,7 @@ export type EntityWhere<TEntity> = ComposedWhere<TEntity> & FieldsWhere<TEntity>
*/
export type EntityOrderBy<TEntity> = {
readonly [K in keyof TEntity]?:
NonNullable<TEntity[K]> extends Array<any>
NonNullable<TEntity[K]> extends readonly unknown[]
? never // has-many cannot be ordered by directly
: IsPlainObject<NonNullable<TEntity[K]>> extends true
? EntityOrderBy<NonNullable<TEntity[K]>> | null // has-one: nested ordering
Expand Down Expand Up @@ -122,12 +112,12 @@ export interface AliasOptions<TAlias extends string = string> {
/**
* Extracts the item type from an array type
*/
export type ArrayItemType<T> = T extends Array<infer U> ? U : never
export type ArrayItemType<T> = T extends readonly (infer U)[] ? U : never

/**
* Checks if a type is an array
*/
export type IsArray<T> = T extends Array<any> ? true : false
export type IsArray<T> = T extends readonly unknown[] ? true : false

/**
* Extracts non-nullable type
Expand Down
9 changes: 6 additions & 3 deletions packages/bindx-client/src/selection/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -60,7 +61,7 @@ export interface SelectionMeta {
/**
* Extract item type from array
*/
type ArrayItemType<T> = T extends Array<infer U> ? U : never
type ArrayItemType<T> = T extends readonly (infer U)[] ? U : never

/**
* A fragment defined with the fluent builder
Expand Down Expand Up @@ -267,8 +268,10 @@ export interface HasManyMethod<
* Maps entity fields to their corresponding builder methods
*/
type SelectionBuilderMethods<TEntity, TSelected extends object, THasManyParams extends object> = {
[K in keyof TEntity]-?: TEntity[K] extends Array<infer U>
? HasManyMethod<TEntity, K, U, TSelected, THasManyParams>
[K in keyof TEntity]-?: NonNullable<TEntity[K]> extends readonly (infer U)[]
? IsPlainObject<U> extends true
? HasManyMethod<TEntity, K, U, TSelected, THasManyParams>
: ScalarMethod<TEntity, K, TSelected, THasManyParams>
: NonNullable<TEntity[K]> extends object
? HasOneMethod<TEntity, K, NonNullable<TEntity[K]>, TSelected, THasManyParams>
: ScalarMethod<TEntity, K, TSelected, THasManyParams>
Expand Down
16 changes: 16 additions & 0 deletions packages/bindx-client/src/utils/fieldShape.ts
Original file line number Diff line number Diff line change
@@ -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> =
T extends Date ? false
: T extends Function ? false
: T extends readonly unknown[] ? false
: T extends object ? true
: false
29 changes: 16 additions & 13 deletions packages/bindx/src/handles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* - EntityRef / EntityAccessor
*/

import type { IsPlainObject } from '@contember/bindx-client'
import type { FieldHandle } from './FieldHandle.js'

// ============================================================================
Expand Down Expand Up @@ -51,8 +52,10 @@ export type { UnsubscribeType as Unsubscribe }
// ============================================================================

export type ScalarKeys<T> = {
[K in keyof T]: T[K] extends (infer _U)[]
? never
[K in keyof T]: NonNullable<T[K]> extends readonly (infer U)[]
? IsPlainObject<U> extends true
? never
: K
: NonNullable<T[K]> extends object
? K extends 'id'
? K
Expand All @@ -61,15 +64,15 @@ export type ScalarKeys<T> = {
}[keyof T]

export type HasManyKeys<T> = {
[K in keyof T]: T[K] extends (infer U)[]
? U extends object
[K in keyof T]: NonNullable<T[K]> extends readonly (infer U)[]
? IsPlainObject<U> extends true
? K
: never
: never
}[keyof T]

export type HasOneKeys<T> = {
[K in keyof T]: T[K] extends (infer _U)[]
[K in keyof T]: NonNullable<T[K]> extends readonly (infer _U)[]
? never
: NonNullable<T[K]> extends object
? K extends 'id'
Expand Down Expand Up @@ -413,8 +416,8 @@ export type EntityAccessorLike<TEntity> = EntityRefLike<TEntity> & {
export type EntityFields<T> = {
[K in ScalarKeys<T>]: FieldHandle<T[K]>
} & {
[K in HasManyKeys<T>]: T[K] extends (infer U)[]
? U extends object
[K in HasManyKeys<T>]: NonNullable<T[K]> extends readonly (infer U)[]
? IsPlainObject<U> extends true
? HasManyAccessor<U>
: never
: never
Expand All @@ -427,9 +430,9 @@ export type EntityFields<T> = {
*/
type FieldRefType<TEntity, TSelected, TSchema extends Record<string, object>, K extends keyof TEntity & keyof TSelected> =
K extends ScalarKeys<TEntity> ? FieldRef<TEntity[K]> :
K extends HasManyKeys<TEntity> ? (TEntity[K] extends (infer U)[]
? U extends object
? HasManyRef<U, ExtractNestedSelection<TSelected, K> extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType<TSchema, U>, TSchema>
K extends HasManyKeys<TEntity> ? (NonNullable<TEntity[K]> extends readonly (infer U)[]
? IsPlainObject<U> extends true
? HasManyRef<U, ExtractNestedSelection<TSelected, K> extends readonly (infer S)[] ? S : U, AnyBrand, EntityNameFromType<TSchema, U>, TSchema>
: never
: never) :
K extends HasOneKeys<TEntity> ? HasOneRef<
Expand All @@ -446,9 +449,9 @@ type FieldRefType<TEntity, TSelected, TSchema extends Record<string, object>, K
*/
type FieldAccessorType<TEntity, TSelected, TSchema extends Record<string, object>, K extends keyof TEntity & keyof TSelected> =
K extends ScalarKeys<TEntity> ? FieldAccessor<TEntity[K]> :
K extends HasManyKeys<TEntity> ? (TEntity[K] extends (infer U)[]
? U extends object
? HasManyAccessor<U, ExtractNestedSelection<TSelected, K> extends (infer S)[] ? S : U, AnyBrand, EntityNameFromType<TSchema, U>, TSchema>
K extends HasManyKeys<TEntity> ? (NonNullable<TEntity[K]> extends readonly (infer U)[]
? IsPlainObject<U> extends true
? HasManyAccessor<U, ExtractNestedSelection<TSelected, K> extends readonly (infer S)[] ? S : U, AnyBrand, EntityNameFromType<TSchema, U>, TSchema>
: never
: never) :
K extends HasOneKeys<TEntity> ? HasOneAccessor<
Expand Down
138 changes: 138 additions & 0 deletions tests/typeSafety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import type {
EntityFieldsAccessor,
EntityFromProp,
SelectionFromProp,
ScalarKeys,
HasManyKeys,
HasOneKeys,
FieldAccessor,
HasManyAccessor,
} from '@contember/bindx-react'
import {
createFragment,
Expand Down Expand Up @@ -639,3 +644,136 @@ describe('Type Safety - Integration', () => {
assertFalse<AssertExtends<'content', keyof ArticleResult>>()
})
})

// 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<Lesson>` is `'id' | 'title'` — `groupSize` is missing.
assertTrue<AssertExtends<'groupSize', ScalarKeys<Lesson>>>()
})

test('a list scalar column accessor exposes .value / .setValue', () => {
type LessonAcc = EntityAccessor<Lesson, { groupSize: readonly string[] }>
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<T>` is invariant in `T` (`inputProps.setValue` is a function-typed
// property), so the accessor is compared against the column's own type.
assertTrue<AssertExtends<GroupSizeField, FieldAccessor<Lesson['groupSize']>>>()
assertTrue<AssertExtends<GroupSizeField['value'], Lesson['groupSize'] | null>>()
assertTrue<AssertExtends<GroupSizeField['setValue'], (value: Lesson['groupSize'] | null) => 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<infer U>` 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>('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<AssertExtends<'weekdays', ScalarKeys<Course>>>()
assertTrue<AssertExtends<'tags', ScalarKeys<Course>>>()
assertTrue<AssertExtends<'metadata', ScalarKeys<Course>>>()
assertTrue<AssertExtends<'published', ScalarKeys<Course>>>()
assertTrue<AssertExtends<'id', ScalarKeys<Course>>>()
})

test('a list scalar column is neither a has-one nor a has-many key', () => {
assertFalse<AssertExtends<'groupSize', HasOneKeys<Lesson>>>()
assertFalse<AssertExtends<'groupSize', HasManyKeys<Lesson>>>()
assertFalse<AssertExtends<'weekdays', HasOneKeys<Course>>>()
assertFalse<AssertExtends<'weekdays', HasManyKeys<Course>>>()
assertFalse<AssertExtends<'tags', HasOneKeys<Course>>>()
assertFalse<AssertExtends<'tags', HasManyKeys<Course>>>()
// 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<AssertExtends<'metadata', HasManyKeys<Course>>>()
assertFalse<AssertExtends<'metadata', HasOneKeys<Course>>>()
})

test('a readonly has-many relation is still a has-many key', () => {
assertTrue<AssertExtends<'chapters', HasManyKeys<Course>>>()
assertTrue<AssertExtends<'archivedChapters', HasManyKeys<Course>>>()
assertFalse<AssertExtends<'chapters', ScalarKeys<Course>>>()
assertFalse<AssertExtends<'archivedChapters', ScalarKeys<Course>>>()
assertFalse<AssertExtends<'archivedChapters', HasOneKeys<Course>>>()
})

test('has-one relations keep their classification', () => {
assertTrue<AssertExtends<'author', HasOneKeys<Course>>>()
assertTrue<AssertExtends<'owner', HasOneKeys<Course>>>()
assertFalse<AssertExtends<'author', ScalarKeys<Course>>>()
assertFalse<AssertExtends<'owner', HasManyKeys<Course>>>()
})

test('a readonly has-many relation resolves to a HasManyAccessor', () => {
type CourseAcc = EntityAccessor<Course, { archivedChapters: { id: string }[] }>
type ArchivedField = CourseAcc['$fields']['archivedChapters']

assertTrue<AssertExtends<ArchivedField, HasManyAccessor<Chapter, { id: string }>>>()
})
})