diff --git a/.changeset/property-definition-navigation.md b/.changeset/property-definition-navigation.md new file mode 100644 index 00000000..f1f16c32 --- /dev/null +++ b/.changeset/property-definition-navigation.md @@ -0,0 +1,14 @@ +--- +'@cleverbrush/knex-schema': patch +'@cleverbrush/orm': patch +'@cleverbrush/mapper': patch +'@cleverbrush/schema-json': patch +'@cleverbrush/server': patch +'@cleverbrush/deep': patch +--- + +Preserve original property declarations and JSDoc through derived types so +editors can navigate to definitions and show property documentation. This covers +query selectors, rows, projections and write payloads; declared relation includes; +mapper targets; JSON Schema inferred values; composed API groups and injected +services; and merged object properties. Runtime behavior is unchanged. diff --git a/libs/deep/src/deepExtend.ts b/libs/deep/src/deepExtend.ts index fed90c47..0c524ca9 100644 --- a/libs/deep/src/deepExtend.ts +++ b/libs/deep/src/deepExtend.ts @@ -16,9 +16,17 @@ type DeepSafeMergeProps = { [K in keyof SafeMergeProps]: DeepSafeMergeValue>; }; +type CommonSource = Pick< + SafeMergeProps, + keyof SafeMergeProps & keyof SafeMergeProps +>; + /** Properties that exist in both `T1` and `T2`, typed as `T2`'s version. */ export type CommonProps = { - [k in keyof SafeMergeProps & keyof SafeMergeProps]: SafeProp< + // Shared values come from T2; retain that property's declaration and JSDoc. + // Required makes the slots required without removing undefined from T2's + // optional values when they are read through SafeProp below. + -readonly [k in keyof Required>]: SafeProp< T1, k > extends never diff --git a/libs/knex-schema/src/PolymorphicQueryBuilder.ts b/libs/knex-schema/src/PolymorphicQueryBuilder.ts index b412ec7a..ad239e97 100644 --- a/libs/knex-schema/src/PolymorphicQueryBuilder.ts +++ b/libs/knex-schema/src/PolymorphicQueryBuilder.ts @@ -69,7 +69,9 @@ type BranchProps> = Omit< keyof ReadRelations> | VariantKey >; type OrphanProps = { - [K in keyof P & string]: V extends { allowOrphan: true } + -readonly [K in keyof P as K extends string ? K : never]-?: V extends { + allowOrphan: true; + } ? SchemaForValue | null> : P[K] extends ReadSchema ? P[K] @@ -95,12 +97,21 @@ export type VariantReadSchema< VariantKey | Discriminator >, VariantMap[K] - > & - Record, SchemaBuilder> + > & { + -readonly [P in keyof Pick< + SchemaProps, + Extract, keyof SchemaProps> + >]-?: SchemaBuilder; + } & Record< + Exclude, keyof SchemaProps>, + SchemaBuilder + > >; /** Per-variant schemas for explicit application-level discriminator dispatch. */ export type VariantReadSchemas = { - [K in keyof VariantMap & string]: VariantReadSchema; + -readonly [K in keyof VariantMap as K extends string + ? K + : never]-?: VariantReadSchema; }; /** The genuine union schema returned for polymorphic read rows. */ export type PolymorphicRowSchema> = @@ -531,11 +542,15 @@ export class PolymorphicQueryBuilder< S, { [P in keyof B]: ObjectSchemaBuilder< - SchemaProps & - Record< - K, - RelationField[K], Child['rowSchema']> - > + SchemaProps & { + -readonly [R in keyof Pick< + ReadRelations, + K + >]-?: RelationField< + ReadRelations[K], + Child['rowSchema'] + >; + } >; } > { diff --git a/libs/knex-schema/src/SchemaQueryBuilder.ts b/libs/knex-schema/src/SchemaQueryBuilder.ts index 6c100f42..f29fa4a3 100644 --- a/libs/knex-schema/src/SchemaQueryBuilder.ts +++ b/libs/knex-schema/src/SchemaQueryBuilder.ts @@ -94,6 +94,9 @@ export interface ReadColumn /** Type-only property identity for column-list projections. */ readonly __property?: K; } +// Map directly over source properties and filter in `as` so TypeScript retains +// their declarations/JSDoc. Mapping a computed key union loses those origins. +// Explicit modifiers keep the existing required, mutable column slots. /** A JSON object column also exposes its known nested properties. */ export type NestedReadColumn = ReadColumn< ColumnReadSchema, @@ -101,9 +104,11 @@ export type NestedReadColumn = ReadColumn< > & (S extends ReadObject ? { - [K in keyof SchemaProps & string]: NestedReadColumn< + -readonly [K in keyof SchemaProps as K extends string + ? K + : never]-?: NestedReadColumn< SchemaProps[K], - `${Key}.${K}` + `${Key}.${K & string}` >; } : {}); @@ -112,10 +117,11 @@ export type ReadColumns< S extends ReadObject, Relations extends PropertyKey = never > = { - [K in Exclude, Relations> & string]: NestedReadColumn< - SchemaProps[K], - K - >; + -readonly [K in keyof SchemaProps as K extends Relations + ? never + : K extends string + ? K + : never]-?: NestedReadColumn[K], K & string>; }; type SelectedValue = | (Selector extends (...args: any[]) => AliasedColumn @@ -127,13 +133,10 @@ type SelectedValue = : never) | null; type Selection = Record | AggregateExpression>; +// Keep each side's property origins while retaining the right side's precedence. type MergeProps = { - [K in keyof A | keyof B]: K extends keyof B - ? B[K] - : K extends keyof A - ? A[K] - : never; -}; + -readonly [K in keyof Required as K extends keyof B ? never : K]: A[K]; +} & { -readonly [K in keyof Required]: B[K] }; type NamedProjections = S extends { readonly [EXTRA_TYPE_BRAND]?: infer P } ? P : {}; @@ -145,7 +148,9 @@ type NamedKeys< : never; /** Structural schema inferred from a typed projection. */ export type ReadProjection = ObjectSchemaBuilder<{ - [K in keyof S & string]: S[K] extends ReadColumn + -readonly [K in keyof S as K extends string + ? K + : never]-?: S[K] extends ReadColumn ? R : S[K] extends AggregateExpression ? SchemaForValue @@ -154,8 +159,14 @@ export type ReadProjection = ObjectSchemaBuilder<{ type AddField< S extends ReadObject, K extends string, - F extends ReadSchema -> = ObjectSchemaBuilder, K> & Record>; + F extends ReadSchema, + Origin = SchemaProps +> = ObjectSchemaBuilder< + // Declared includes have an origin; ad-hoc join aliases may introduce a key. + Omit, K> & { + -readonly [P in keyof Pick>]-?: F; + } & Record, F> +>; /** @internal Foreign schema retained by a declared relation. */ export type Related = R extends RelationInfo ? S : never; /** @internal Output cardinality and nullability of a loaded relation. */ @@ -376,7 +387,9 @@ export class SchemaQueryBuilder< 'Scopes must synchronously return the supplied query with filters, ordering or pagination only' ); } - return result as this; + // The runtime checks above preserve this query's shape; instanceof + // alone cannot recover its schema and relation type parameters. + return result as unknown as this; } /** Apply a named, synchronous shape-preserving scope once to an independent query. */ @@ -956,7 +969,12 @@ export class SchemaQueryBuilder< customize?: (query: SchemaAwareQuery>) => Child ): SchemaQueryBuilder< S, - AddField>, + AddField< + Row, + K, + RelationField, + Relations + >, Relations, false > { diff --git a/libs/knex-schema/src/entity.ts b/libs/knex-schema/src/entity.ts index d226d506..3953f457 100644 --- a/libs/knex-schema/src/entity.ts +++ b/libs/knex-schema/src/entity.ts @@ -176,7 +176,16 @@ export type WithRelation< TReadVariants extends ReadVariants = {} > = Entity< TSchema, - TRels & Record>, + // Derive metadata from schema properties so includes retain their origins. + TRels & { + -readonly [P in keyof Pick< + SchemaProps, + Extract> + >]-?: RelationInfo; + } & Record< + Exclude>, + RelationInfo + >, TVariantUnion, TReadVariants >; @@ -377,11 +386,14 @@ export class Entity< opts?: { optional?: TOptional } ): Entity< TSchema, - TRels & - Record< - TKey, - RelationInfo<'belongsTo', TForeign> & { optional: TOptional } - >, + TRels & { + -readonly [P in keyof Pick< + SchemaProps, + TKey + >]-?: RelationInfo<'belongsTo', TForeign> & { + optional: TOptional; + }; + }, TVariantUnion, TReadVariants > { diff --git a/libs/knex-schema/src/property-navigation.test.ts b/libs/knex-schema/src/property-navigation.test.ts new file mode 100644 index 00000000..79604ae0 --- /dev/null +++ b/libs/knex-schema/src/property-navigation.test.ts @@ -0,0 +1,211 @@ +import { readFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +// Exercise the whole public type pipeline in one language service, including +// downstream ORM, mapper, form and client consumers of the emitted declarations. +const model = fileURLToPath( + new URL('../test-fixtures/property-navigation.model.ts', import.meta.url) +); +const consumer = fileURLToPath( + new URL('../test-fixtures/property-navigation.consumer.js', import.meta.url) +); +const compatibility = fileURLToPath( + new URL('../test-fixtures/property-navigation.types.ts', import.meta.url) +); +const sources = new Map( + [model, consumer, compatibility].map(file => [ + file, + readFileSync(file, 'utf8') + ]) +); +const modelSource = sources.get(model)!; + +const documentation: Record = { + 'infer-name': 'Plain name.', + 'infer-optional': 'Plain optional age.', + 'infer-array': 'Tag label.', + 'input-default': 'Plain defaulted count.', + validation: 'Plain name.', + 'form-field': 'Plain name.', + 'form-array': 'Tag label.', + 'form-value': 'Plain name.', + 'mapper-target': 'Target label.', + 'mapper-source': 'Plain name.', + 'mapper-compute': 'Plain name.', + picked: 'Plain name.', + 'entity-nav': 'Department navigation.', + where: 'User first name.', + 'optional-column': 'User optional score.', + 'read-row': 'User first name.', + 'read-row-json': 'Profile city.', + 'row-schema': 'User first name.', + 'projection-row': 'Projected name.', + 'projection-schema': 'Projected name.', + 'insert-key': 'User first name.', + 'update-key': 'User first name.', + 'insert-value': 'User first name.', + 'query-include': 'Department navigation.', + 'included-nav': 'Department navigation.', + 'included-child': 'Department title.', + 'customized-child': 'Department display label.', + 'db-set': 'Users collection.', + 'orm-where': 'User first name.', + 'orm-include': 'Department navigation.', + 'orm-row': 'User first name.', + 'orm-nav': 'Department navigation.', + 'many-include': 'Owner tasks.', + 'many-result': 'Owner tasks.', + 'many-child': 'Task title.', + 'poly-base': 'Asset id.', + 'poly-field': 'Photo size.', + 'poly-discriminator': 'Asset kind.', + 'poly-schema': 'Photo size.', + 'poly-include': 'Asset department.', + 'poly-relation': 'Asset department.', + 'json-required': 'JSON name.', + 'json-optional': 'JSON age.', + 'json-builder': 'JSON name.', + 'route-param': 'Route id.', + 'merged-group': 'Public users.', + 'merged-endpoint': 'Get users.', + 'client-group': 'Public users.', + 'client-endpoint': 'Get users.', + 'overlap-endpoint': 'User detail operation.', + 'handler-input': 'Plain name.', + 'injected-service': 'Scope dependency.', + 'overridden-service': 'Operation dependency.', + 'service-property': 'Injected value.', + 'deep-shared': 'Right property.', + 'deep-child': 'Right child.', + 'deep-left': 'Left only.', + 'deep-optional': 'Optional merge value.' +}; + +const references = [...sources].flatMap(([file, source]) => + [...source.matchAll(/\/\*([a-z-]+)\*\/\s*(\w+)/g)].map(match => ({ + file, + name: match[1], + property: match[2], + position: match.index + match[0].length - match[2].length, + language: file.endsWith('.ts') ? 'TypeScript' : 'JavaScript' + })) +); + +let service: ts.LanguageService; +beforeAll(() => { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + allowJs: true, + checkJs: true, + noEmit: true, + esModuleInterop: true, + types: ['node'] + }; + service = ts.createLanguageService({ + ...ts.sys, + useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, + getScriptFileNames: () => [...sources.keys()], + getScriptVersion: () => '0', + getScriptSnapshot: file => { + const text = sources.get(file) ?? ts.sys.readFile(file); + return text === undefined + ? undefined + : ts.ScriptSnapshot.fromString(text); + }, + getCompilationSettings: () => options, + getCurrentDirectory: () => dirname(model), + getDefaultLibFileName: ts.getDefaultLibFilePath + }); +}); +afterAll(() => service?.dispose()); + +test('navigation fixtures typecheck against public package declarations', () => { + // Formatting must not silently stop the marker parser from finding cases. + expect(references.length).toBeGreaterThanOrEqual(64); + expect(new Set(references.map(reference => reference.name))).toEqual( + new Set(Object.keys(documentation)) + ); + const diagnostics = [...sources.keys()].flatMap(file => [ + ...service.getSyntacticDiagnostics(file), + ...service.getSemanticDiagnostics(file) + ]); + expect( + diagnostics.map(d => + ts.flattenDiagnosticMessageText(d.messageText, '\n') + ) + ).toEqual([]); + const files = service.getProgram()!.getSourceFiles(); + for (const name of [ + 'knex-schema', + 'orm', + 'mapper', + 'schema-json', + 'server', + 'deep', + 'schema', + 'react-form', + 'client' + ]) { + expect( + files.some(file => + file.fileName.endsWith(`/libs/${name}/dist/index.d.ts`) + ), + `${name} must be tested through its published declarations` + ).toBe(true); + } +}, 30000); + +describe.each(references)('$language $name ($property)', reference => { + test('goes to the original property declaration', () => { + const doc = documentation[reference.name]; + expect(doc).toBeDefined(); + const comment = `/** ${doc} */`; + const offset = modelSource.indexOf(comment); + expect(offset).toBeGreaterThanOrEqual(0); + const scanner = ts.createScanner( + ts.ScriptTarget.ES2022, + true, + ts.LanguageVariant.Standard, + modelSource + ); + scanner.setTextPos(offset + comment.length); + scanner.scan(); + if (scanner.getToken() === ts.SyntaxKind.ReadonlyKeyword) + scanner.scan(); + expect(scanner.getTokenText()).toBe(reference.property); + const definitions = service.getDefinitionAtPosition( + reference.file, + reference.position + ); + expect(definitions).toContainEqual( + expect.objectContaining({ + fileName: model, + textSpan: { + start: scanner.getTokenPos(), + length: reference.property.length + } + }) + ); + }); + + // TypeScript omits hover docs for this union-context object literal even + // when both branches navigate to the same schema. Keep the accepted union. + if (reference.name !== 'update-key') { + test('shows the original JSDoc on hover', () => { + const info = service.getQuickInfoAtPosition( + reference.file, + reference.position + ); + expect(ts.displayPartsToString(info?.documentation)).toBe( + documentation[reference.name] + ); + }); + } +}); diff --git a/libs/knex-schema/src/read-schema.ts b/libs/knex-schema/src/read-schema.ts index 68666b36..45c318ce 100644 --- a/libs/knex-schema/src/read-schema.ts +++ b/libs/knex-schema/src/read-schema.ts @@ -20,6 +20,8 @@ import { assertJsonValue } from './json-validation.js'; export type ReadSchema = SchemaBuilder; /** A table/object schema accepted by schema-aware read queries. */ export type ReadObject = ObjectSchemaBuilder; +// `keyof` plus key filtering preserves source declarations through decoded +// values and rowSchema; computed key unions discard navigation and hover docs. /** Reconstruct structural schema types without discarding nested property schemas. */ export type SchemaForValue = 0 extends 1 & T ? ReadSchema @@ -42,9 +44,9 @@ export type SchemaForValue = 0 extends 1 & T : NonNullable extends object ? ObjectSchemaBuilder< { - [K in keyof NonNullable & string]-?: SchemaForValue< - NonNullable[K] - >; + -readonly [K in keyof NonNullable as K extends string + ? K + : never]-?: SchemaForValue[K]>; }, undefined extends T ? false : true, null extends T ? true : false @@ -81,9 +83,11 @@ export type ColumnReadSchema = SchemaForValue>; export type ObjectReadSchema = S extends ObjectSchemaBuilder ? ObjectSchemaBuilder<{ - [K in Exclude & string]: ColumnReadSchema< - P[K] - >; + -readonly [K in keyof P as K extends Relations + ? never + : K extends string + ? K + : never]-?: ColumnReadSchema; }> : never; diff --git a/libs/knex-schema/src/types.ts b/libs/knex-schema/src/types.ts index 8eef5663..9b531663 100644 --- a/libs/knex-schema/src/types.ts +++ b/libs/knex-schema/src/types.ts @@ -262,9 +262,10 @@ export type ValidatedSpec = export type InsertType< T extends ObjectSchemaBuilder > = { - [K in Exclude, keyof ReadRelations>]?: - | InferType[K]> - | ReadValue[K]>; + // Retain the declared property for both payload access and contextual keys. + -readonly [K in keyof SchemaProps as K extends keyof ReadRelations + ? never + : K]?: InferType[K]> | ReadValue[K]>; }; // --------------------------------------------------------------------------- diff --git a/libs/knex-schema/test-fixtures/property-navigation.consumer.js b/libs/knex-schema/test-fixtures/property-navigation.consumer.js new file mode 100644 index 00000000..dfa65b82 --- /dev/null +++ b/libs/knex-schema/test-fixtures/property-navigation.consumer.js @@ -0,0 +1,34 @@ +// Checked JavaScript consumers must retain the same declaration origins as TS. +import { query } from '@cleverbrush/knex-schema'; +import { mapper } from '@cleverbrush/mapper'; +import { useSchemaForm } from '@cleverbrush/react-form'; +import Knex from 'knex'; +import { + db, + Entity, + Plain, + projected, + read, + Target +} from './property-navigation.model.js'; + +query(Knex({ client: 'pg' }), Entity.schema).where( + t => t./*where*/ firstName, + 'John' +); +read.select(t => ({ name: t./*where*/ firstName })); +read.orderBy(t => t./*where*/ firstName); +read.where(group => group.where(t => t./*where*/ firstName, 'John')); +read.where(t => t.profile./*read-row-json*/ city, 'London'); +read.insert({ /*insert-key*/ firstName: 'Jane' }); +const rows = await read.include(t => t./*query-include*/ department); +rows[0]./*included-nav*/ department?./*included-child*/ title; +rows[0]./*read-row*/ firstName; +(await projected)[0]./*projection-row*/ displayName; +mapper().configure(Plain, Target, m => + m.for(t => t./*mapper-target*/ label).from(s => s./*mapper-source*/ name) +); +const form = useSchemaForm(Plain); +form.useField(t => t./*form-field*/ name); +form.useField(t => t.tags[0]./*form-array*/ label); +db.users.include(t => t./*orm-include*/ department); diff --git a/libs/knex-schema/test-fixtures/property-navigation.model.ts b/libs/knex-schema/test-fixtures/property-navigation.model.ts new file mode 100644 index 00000000..35b633ef --- /dev/null +++ b/libs/knex-schema/test-fixtures/property-navigation.model.ts @@ -0,0 +1,365 @@ +import { createClient } from '@cleverbrush/client'; +import { deepExtend } from '@cleverbrush/deep'; +import { + alias, + defineEntity, + type InsertType, + number as int, + query, + object as table, + string as text +} from '@cleverbrush/knex-schema'; +import { mapper } from '@cleverbrush/mapper'; +import type { WithIncluded, WithVariantIncluded } from '@cleverbrush/orm'; +import { createDb } from '@cleverbrush/orm'; +import type { SchemaFormInstance } from '@cleverbrush/react-form'; +import { + array, + type InferType, + number, + object, + string +} from '@cleverbrush/schema'; +import { + fromJsonSchema, + type InferFromJsonSchema +} from '@cleverbrush/schema-json'; +import { + defineApi, + endpoint, + implement, + mergeContracts, + route +} from '@cleverbrush/server'; +import Knex from 'knex'; + +const Plain = object({ + /** Plain name. */ + name: string(), + /** Plain optional age. */ + age: number().optional(), + /** Plain defaulted count. */ + count: number().default(0), + address: object({ + /** Address city. */ + city: string() + }), + tags: array( + object({ + /** Tag label. */ + label: string() + }) + ) +}); +declare const plain: InferType; +plain./*infer-name*/ name; +plain./*infer-optional*/ age; +plain.tags[0]./*infer-array*/ label; +declare const input: Parameters[0]; +input./*input-default*/ count; +Plain.validate({} as any).getErrorsFor(t => t./*validation*/ name); +declare const form: SchemaFormInstance; +form.useField(t => t./*form-field*/ name); +form.useField(t => t.tags[0]./*form-array*/ label); +form.getValue()./*form-value*/ name; +const Target = object({ + /** Target label. */ + label: string() +}); +mapper().configure(Plain, Target, m => + m.for(t => t./*mapper-target*/ label).from(t => t./*mapper-source*/ name) +); +mapper().configure(Plain, Target, m => + m.for(t => t.label).compute(t => t./*mapper-compute*/ name) +); +const Picked = Plain.pick('name'); +Picked.validate({} as any).getErrorsFor(t => t./*picked*/ name); + +const Dept = table({ + /** Department id. */ + id: int().primaryKey(), + /** Department title. */ + title: text() +}).hasTableName('departments'); +const User = table({ + /** User id. */ + id: int().primaryKey(), + /** User first name. */ + firstName: text().hasColumnName('first_name'), + /** User optional score. */ + score: int().optional(), + /** Department foreign key. */ + departmentId: int(), + /** Department navigation. */ + department: Dept.optional(), + profile: table({ + /** Profile city. */ + city: text() + }) +}).hasTableName('users'); +const Entity = defineEntity(User).belongsTo( + t => t./*entity-nav*/ department, + t => t.departmentId, + d => d.id +); +const knex = Knex({ client: 'pg' }); +const read = query(knex, Entity.schema); +read.where(t => t./*where*/ firstName, 'John'); +read.whereNull(t => t./*optional-column*/ score); +read.select('firstName') + .rowSchema.validate({ firstName: 'Jane' }) + .getErrorsFor(t => t./*row-schema*/ firstName); +query(knex, alias(Entity.schema, 'user')).where( + t => t.user./*where*/ firstName, + 'John' +); +const rows = await read; +rows[0]./*read-row*/ firstName; +rows[0].profile./*read-row-json*/ city; +read.rowSchema + .validate({} as any) + .getErrorsFor(t => t./*row-schema*/ firstName); +const projected = read.select(t => ({ + /** Projected name. */ + displayName: t.firstName +})); +const projectedRows = await projected; +projectedRows[0]./*projection-row*/ displayName; +projected.rowSchema + .validate({} as any) + .getErrorsFor(t => t./*projection-schema*/ displayName); +read.insert({ /*insert-key*/ firstName: 'Jane' }); +read.update({ /*update-key*/ firstName: 'Jane' }); +declare const payload: InsertType; +payload./*insert-value*/ firstName; +const included = read.include(t => t./*query-include*/ department); +const includedRows = await included; +includedRows[0]./*included-nav*/ department?.title; +includedRows[0].department?./*included-child*/ title; +const customized = read + .include( + t => t.department, + child => + child.select(t => ({ + /** Department display label. */ + label: t./*included-child*/ title + })) + ) + .select(t => ({ + /** Customized user name. */ + name: t.firstName + })); +(await customized)[0]./*included-nav*/ department?./*customized-child*/ label; +const db = createDb(knex, { + /** Users collection. */ + users: Entity +}); +db./*db-set*/ users; +db.users.where(t => t./*orm-where*/ firstName, 'John'); +const ormIncluded = db.users.include(t => t./*orm-include*/ department); +const ormRows = await ormIncluded; +ormRows[0]./*orm-row*/ firstName; +ormRows[0]./*orm-nav*/ department?.title; +declare const withIncluded: WithIncluded; +withIncluded./*orm-nav*/ department?./*included-child*/ title; +declare const withVariant: WithVariantIncluded< + typeof Entity, + { type: 'photo' }, + 'photo', + 'department' +>; +withVariant./*orm-nav*/ department?./*included-child*/ title; + +const Task = table({ + id: int().primaryKey(), + ownerId: int(), + /** Task title. */ + title: text() +}).hasTableName('tasks'); +const Owner = defineEntity( + table({ + id: int().primaryKey(), + /** Owner tasks. */ + tasks: array(Task).optional() + }).hasTableName('owners') +).hasMany( + t => t.tasks, + t => t.id, + t => t.ownerId +); +const ownerRead = query(knex, Owner.schema).include( + t => t./*many-include*/ tasks +); +(await ownerRead)[0]./*many-result*/ tasks[0]./*many-child*/ title; +const owners = createDb(knex, { owners: Owner }).owners.include( + t => t./*many-include*/ tasks +); +(await owners)[0]./*many-result*/ tasks[0]./*many-child*/ title; + +const Base = defineEntity( + table({ + /** Asset id. */ + id: int().primaryKey(), + /** Asset kind. */ + kind: text(), + departmentId: int(), + /** Asset department. */ + department: Dept.optional() + }).hasTableName('assets') +) + .belongsTo( + t => t.department, + t => t.departmentId, + t => t.id, + { optional: true } + ) + .discriminator(t => t.kind) + .stiVariant( + 'photo', + table({ + /** Photo size. */ + size: int() + }) + ); +const poly = query(knex, Base.schema); +const polyRows = await poly; +if (polyRows[0].kind === 'photo') { + polyRows[0]./*poly-base*/ id; + polyRows[0]./*poly-field*/ size; + polyRows[0]./*poly-discriminator*/ kind; +} +poly.variantRowSchemas.photo + .validate({} as any) + .getErrorsFor(t => t./*poly-schema*/ size); +const polyIncluded = poly.include(t => t./*poly-include*/ department); +(await polyIncluded)[0]./*poly-relation*/ department?./*included-child*/ title; + +const JSONSchema = { + type: 'object', + properties: { + /** JSON name. */ + name: { type: 'string' }, + /** JSON age. */ + age: { type: 'number' } + }, + required: ['name'] +} as const; +declare const fromJson: InferFromJsonSchema; +fromJson./*json-required*/ name; +fromJson./*json-optional*/ age; +fromJsonSchema(JSONSchema) + .validate({} as any) + .getErrorsFor(t => t./*json-builder*/ name); + +const routeShape = { + /** Route id. */ + id: number() +}; +const endpointDef = endpoint + .resource('/users') + .get(route(routeShape)`/${t => t./*route-param*/ id}`) + .query(Plain) + .responses({ 200: Plain }); +const first = defineApi({ + /** Public users. */ + users: { + /** Get users. */ + list: endpointDef + } +}); +const second = defineApi({ + /** Admin group. */ + admin: { list: endpoint.get('/admin').responses({ 200: Plain }) } +}); +const merged = mergeContracts(first, second); +merged./*merged-group*/ users./*merged-endpoint*/ list; +const client = createClient(merged); +client./*client-group*/ users./*client-endpoint*/ list; +const extra = defineApi({ + users: { + /** User detail operation. */ + detail: endpoint.get('/users/detail').responses({ 200: Plain }) + } +}); +const overlapping = mergeContracts(first, extra); +createClient(overlapping).users./*overlap-endpoint*/ detail; +implement(first) + .group('users') + .withHandlers({ + list: ({ query: args }) => { + args./*handler-input*/ name; + return args; + } + }); +const DbSchema = object({ + /** Injected value. */ + value: string() +}); +const scope = implement(first).group('users', { + inject: { + /** Scope dependency. */ + db: DbSchema + } +}); +scope.withHandlers({ + list: ({ query: args }, services) => { + services./*injected-service*/ db; + services.db./*service-property*/ value; + return args; + } +}); +const overridden = implement(first).group('users', { + inject: { db: DbSchema }, + operations: { + list: { + inject: { + /** Operation dependency. */ + db: object({ replacement: string() }) + } + } + } +}); +overridden.withHandlers({ + list: ({ query: args }, services) => { + services./*overridden-service*/ db.replacement; + return args; + } +}); +const left = { + /** Left property. */ + shared: { + /** Left child. */ + leftChild: 1 + }, + /** Left only. */ + left: 1 +}; +const right = { + /** Right property. */ + shared: { + /** Right child. */ + rightChild: 2 + } +}; +const extended = deepExtend(left, right); +extended./*deep-shared*/ shared./*deep-child*/ rightChild; +extended./*deep-left*/ left; +interface OptionalMergeInput { + /** Optional merge value. */ + readonly value?: string; +} +declare const optionalMergeInput: OptionalMergeInput; +deepExtend({ value: '' }, optionalMergeInput)./*deep-optional*/ value; + +export { + db, + Entity, + JSONSchema, + merged, + Plain, + projected, + read, + scope, + Target, + User +}; diff --git a/libs/knex-schema/test-fixtures/property-navigation.types.ts b/libs/knex-schema/test-fixtures/property-navigation.types.ts new file mode 100644 index 00000000..641a66d0 --- /dev/null +++ b/libs/knex-schema/test-fixtures/property-navigation.types.ts @@ -0,0 +1,156 @@ +import { deepExtend, type MergeTwo } from '@cleverbrush/deep'; +import { + type EntityRelations, + type InsertType, + number, + object, + type ReadColumns, + type SchemaForValue, + string, + type WithRelation +} from '@cleverbrush/knex-schema'; +import { mapper } from '@cleverbrush/mapper'; +import type { InferType, ObjectSchemaBuilder } from '@cleverbrush/schema'; +import type { InferFromJsonSchema } from '@cleverbrush/schema-json'; +import { + type Entity, + type JSONSchema, + Plain, + projected, + read, + Target +} from './property-navigation.model.js'; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type Check = T; +type Shape = { [K in keyof T]: T[K] }; + +const ReadonlySchema = object({ + id: number(), + label: string().optional() +} as const); +declare const columns: ReadColumns; +declare const replacement: ReadColumns; +// Column slots stay present and mutable even when their source record is readonly. +columns.id = replacement.id; +columns.label = replacement.label; +const SymbolKey = Symbol('symbol'); +const MixedKeys = object({ + 1: number(), + name: string(), + [SymbolKey]: string() +}); + +// An indexed source has no concrete declaration, but selected keys still exist. +type IndexedRelation = WithRelation< + ObjectSchemaBuilder>>, + {}, + 'related', + 'hasMany', + typeof ReadonlySchema +>; + +type Rows = InferType; +type Write = InsertType; +const empty: Write = {}; +const nullable: Write = { score: null }; +const optional: Write = { score: undefined }; +// @ts-expect-error declared navigation keys are not database columns +read.where(t => t.department, 1); +// @ts-expect-error unknown properties stay excluded +read.where(t => t.missing, 1); +// @ts-expect-error nested column properties remain checked +read.where(t => t.profile.missing, 'value'); +// @ts-expect-error relation metadata cannot be written as a scalar column +read.insert({ department: {} }); +// @ts-expect-error the column's declared type is still enforced +read.insert({ firstName: 42 }); +// @ts-expect-error unknown relations are not selectable +read.include(t => t.missing); +const customized = read.include( + t => t.department, + child => child.select(t => ({ label: t.title })) +); +const retained = customized.select(t => ({ firstName: t.firstName })); +type Included = InferType; +// @ts-expect-error selected rows cannot write +projected.insert({ firstName: 'Jane' }); + +mapper().configure(Plain, Target, m => + // @ts-expect-error nonexistent target fields are excluded + m.for(t => t.missing).from(t => t.name) +); +// @ts-expect-error all non-automatic target mappings are still required +mapper().configure(Plain, Target, m => m); +const registry = mapper().configure(Plain, Target, m => + m.for(t => t.label).compute(t => t.name) +); +const sync = registry.getSyncMapper(Plain, Target); + +type Json = InferFromJsonSchema; +const json: Json = { name: 'Jane' }; +json.name = 'John'; +json.age = undefined; +// @ts-expect-error required JSON Schema properties remain required +const missingName: Json = {}; + +type OptionalInput = { readonly value?: string }; +declare const optionalInput: OptionalInput; +const merged = deepExtend({ value: '' }, optionalInput); +// Keep the original required slot and optional value, without making it readonly. +merged.value = undefined; +// @ts-expect-error merged shared slots remain required +const missingValue: typeof merged = {}; + +export type Compatibility = [ + Check, 'related'>>, + Check< + Equal< + Rows, + { + id: number; + firstName: string; + score: number | null; + departmentId: number; + profile: { city: string }; + } + > + >, + Check< + Equal, { displayName: string }> + >, + Check< + Equal + >, + Check, 'name'>>, + Check>, + Check, { label: string }>>, + Check, { name: string; age?: number }>>, + Check< + Equal< + Shape>, + { value: string | undefined } + > + >, + Check< + Equal< + SchemaForValue<{ field?: string }> extends ObjectSchemaBuilder< + infer P, + any, + any, + any, + any, + any, + any + > + ? keyof P + : never, + 'field' + > + > +]; + +void [empty, nullable, optional, missingName, missingValue]; diff --git a/libs/mapper/src/MappingRegistry.ts b/libs/mapper/src/MappingRegistry.ts index e07113fb..4adf34a1 100644 --- a/libs/mapper/src/MappingRegistry.ts +++ b/libs/mapper/src/MappingRegistry.ts @@ -164,7 +164,10 @@ type TargetPropertyTree< TSchema extends ObjectSchemaBuilder, TAllowedKeys extends string > = { - [K in SchemaKeys & TAllowedKeys]: TargetPropertyKey & + // Filter source properties without detaching the target key's declaration. + -readonly [K in keyof ExtractSchemaProperties as K extends TAllowedKeys + ? K + : never]-?: TargetPropertyKey & PropertyDescriptor[K], any>; }; diff --git a/libs/orm/src/dbset.ts b/libs/orm/src/dbset.ts index ff5c5787..1f91e510 100644 --- a/libs/orm/src/dbset.ts +++ b/libs/orm/src/dbset.ts @@ -50,7 +50,11 @@ import { } from './variant-write.js'; type EntityRowSchema = ObjectSchemaBuilder< - { [K in keyof T & string]-?: SchemaForValue }, + { + -readonly [K in keyof T as K extends string + ? K + : never]-?: SchemaForValue; + }, true, false, T @@ -176,7 +180,10 @@ export interface TableEntityQuery< ): EntityQuery< TEntity, TResult & { - [P in K]: EntityRelations[K] extends { + -readonly [P in keyof Pick< + EntityRelations, + K + >]-?: EntityRelations[K] extends { kind: 'hasMany' | 'belongsToMany'; } ? InferType[] diff --git a/libs/orm/src/result-types.ts b/libs/orm/src/result-types.ts index 94e67a4e..6e69f6ca 100644 --- a/libs/orm/src/result-types.ts +++ b/libs/orm/src/result-types.ts @@ -69,7 +69,9 @@ export type ResolvedRel = * @public */ export type RelKeyTree> = { - readonly [K in keyof EntityRelations & string]: K; + readonly [K in keyof EntityRelations as K extends string + ? K + : never]-?: K; }; /** Schema of an entity's declared navigation property. */ @@ -87,7 +89,11 @@ export type WithIncluded< TEntity extends Entity, TResult, K extends keyof EntityRelations & string -> = TResult & { [P in K]: ResolvedRel[P]> }; +> = TResult & { + -readonly [P in keyof Pick, K>]-?: ResolvedRel< + EntityRelations[P] + >; +}; /** * Result type after `.includeVariant(variant, rel)` is applied — adds @@ -106,12 +112,14 @@ export type WithVariantIncluded< ? Extract> extends never ? U : Extract> extends U - ? U & { [P in Rel]: ResolvedRel[P]> } + ? WithIncluded : | Exclude>> - | (Extract> & { - [P in Rel]: ResolvedRel[P]>; - }) + | WithIncluded< + TEntity, + Extract>, + Rel + > : U : never; diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts index 089b9748..752cf81f 100644 --- a/libs/schema-json/src/types.ts +++ b/libs/schema-json/src/types.ts @@ -103,13 +103,17 @@ export type InferFromJsonSchema = S extends { readonly const: infer V } string)[]; } ? { - [K in keyof P & string as K extends R - ? K - : never]: InferFromJsonSchema; + -readonly [K in keyof P as K extends string + ? K extends R + ? K + : never + : never]-?: InferFromJsonSchema; } & { - [K in keyof P & string as K extends R - ? never - : K]?: InferFromJsonSchema; + -readonly [K in keyof P as K extends string + ? K extends R + ? never + : K + : never]?: InferFromJsonSchema; } : S extends { readonly type: 'object'; diff --git a/libs/server/src/Implementation.ts b/libs/server/src/Implementation.ts index 15e7fbd6..b2bd9ad1 100644 --- a/libs/server/src/Implementation.ts +++ b/libs/server/src/Implementation.ts @@ -44,13 +44,10 @@ type AnySubscription = SubscriptionBuilder< any >; type Definition = AnyEndpoint | AnySubscription; +// Preserve service declarations through defaults/overrides, with B winning. type Merge = { - [K in keyof A | keyof B]: K extends keyof B - ? B[K] - : K extends keyof A - ? A[K] - : never; -}; + -readonly [K in keyof Required as K extends keyof B ? never : K]: A[K]; +} & { -readonly [K in keyof Required]: B[K] }; /** Server-only enrichment shared by every operation in a scope. */ export interface ImplementationDefaults { @@ -339,8 +336,9 @@ export class ImplementationScope< } type ContractOf = M extends ImplementationModule ? C : never; +// Map over the combined object so groups keep their original declarations. type MergeContracts = { - [G in keyof A | keyof B]: G extends keyof A + -readonly [G in keyof Required]: G extends keyof A ? G extends keyof B ? A[G] & B[G] : A[G] diff --git a/libs/server/src/contract.ts b/libs/server/src/contract.ts index 16ad64f0..64f6d252 100644 --- a/libs/server/src/contract.ts +++ b/libs/server/src/contract.ts @@ -159,7 +159,8 @@ export function defineApi(contract: T): Readonly { * visible on the merged group. */ export type MergedContracts = { - readonly [K in keyof A | keyof B]: K extends keyof A + // Keep group origins instead of synthesizing keys from `keyof A | keyof B`. + readonly [K in keyof Required]: K extends keyof A ? K extends keyof B ? A[K] & B[K] : A[K]