diff --git a/examples/mongo-demo/package.json b/examples/mongo-demo/package.json index 2141bf20ea..e3e2852b4b 100644 --- a/examples/mongo-demo/package.json +++ b/examples/mongo-demo/package.json @@ -25,6 +25,7 @@ "@prisma-next/mongo-pipeline-builder": "workspace:*", "@prisma-next/mongo-query-ast": "workspace:*", "@prisma-next/mongo-runtime": "workspace:*", + "@prisma-next/utils": "workspace:*", "mongodb": "catalog:", "react": "^19.2.4", "react-dom": "^19.2.4" diff --git a/examples/mongo-demo/src/server.ts b/examples/mongo-demo/src/server.ts index cafe261c63..4764a50b71 100644 --- a/examples/mongo-demo/src/server.ts +++ b/examples/mongo-demo/src/server.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { createServer, type ServerResponse } from 'node:http'; -import type { SimplifyDeep } from '@prisma-next/mongo-orm'; +import type { SimplifyDeep } from '@prisma-next/utils/simplify-deep'; if (existsSync('.env')) { process.loadEnvFile('.env'); diff --git a/packages/1-framework/0-foundation/utils/package.json b/packages/1-framework/0-foundation/utils/package.json index 835914e944..80198028e8 100644 --- a/packages/1-framework/0-foundation/utils/package.json +++ b/packages/1-framework/0-foundation/utils/package.json @@ -33,6 +33,7 @@ "./defined": "./dist/defined.mjs", "./redact-db-url": "./dist/redact-db-url.mjs", "./result": "./dist/result.mjs", + "./simplify-deep": "./dist/simplify-deep.mjs", "./package.json": "./package.json" }, "repository": { diff --git a/packages/1-framework/0-foundation/utils/src/exports/simplify-deep.ts b/packages/1-framework/0-foundation/utils/src/exports/simplify-deep.ts new file mode 100644 index 0000000000..7e2a030359 --- /dev/null +++ b/packages/1-framework/0-foundation/utils/src/exports/simplify-deep.ts @@ -0,0 +1 @@ +export type { SimplifyDeep } from '../simplify-deep'; diff --git a/packages/1-framework/0-foundation/utils/src/simplify-deep.ts b/packages/1-framework/0-foundation/utils/src/simplify-deep.ts new file mode 100644 index 0000000000..ad1c84283f --- /dev/null +++ b/packages/1-framework/0-foundation/utils/src/simplify-deep.ts @@ -0,0 +1,18 @@ +export type SimplifyDeep = T extends readonly (infer Element)[] + ? T extends unknown[] + ? SimplifyDeep[] + : readonly SimplifyDeep[] + : T extends + | string + | number + | boolean + | bigint + | symbol + | Date + | RegExp + | Uint8Array + | ((...args: never[]) => unknown) + ? T + : T extends object + ? { [K in keyof T]: SimplifyDeep } + : T; diff --git a/packages/1-framework/0-foundation/utils/test/simplify-deep.test-d.ts b/packages/1-framework/0-foundation/utils/test/simplify-deep.test-d.ts new file mode 100644 index 0000000000..0ad91beadc --- /dev/null +++ b/packages/1-framework/0-foundation/utils/test/simplify-deep.test-d.ts @@ -0,0 +1,80 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { SimplifyDeep } from '../src/simplify-deep'; + +describe('SimplifyDeep', () => { + test('primitives pass through', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('branded primitives pass through', () => { + type Branded = string & { readonly __brand: true }; + expectTypeOf>().toEqualTypeOf(); + }); + + test('Date, RegExp, and Uint8Array preserved', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('functions preserved', () => { + type Fn = (a: number, b: string) => boolean; + expectTypeOf>().toEqualTypeOf(); + }); + + test('intersections flatten into plain objects', () => { + type Input = { a: number } & { b: string }; + type Expected = { a: number; b: string }; + expectTypeOf>().toEqualTypeOf(); + }); + + test('mutable arrays recurse', () => { + type Input = ({ a: number } & { b: string })[]; + type Expected = { a: number; b: string }[]; + expectTypeOf>().toEqualTypeOf(); + }); + + test('readonly arrays preserve readonly', () => { + type Input = readonly ({ a: number } & { b: string })[]; + type Expected = readonly { a: number; b: string }[]; + expectTypeOf>().toEqualTypeOf(); + }); + + test('nested objects recurse', () => { + type Input = { nested: { a: number } & { b: string } }; + type Expected = { nested: { a: number; b: string } }; + expectTypeOf>().toEqualTypeOf(); + }); + + test('nullable objects', () => { + type Input = ({ a: number } & { b: string }) | null; + type Expected = { a: number; b: string } | null; + expectTypeOf>().toEqualTypeOf(); + }); + + test('nested arrays of intersected objects', () => { + type Input = { + items: ({ id: number } & { name: string })[]; + }; + type Expected = { + items: { id: number; name: string }[]; + }; + expectTypeOf>().toEqualTypeOf(); + }); + + test('bidirectional assignability for concrete types', () => { + type Original = { a: number } & { b: string; nested: { c: boolean } & { d: number } }; + type Simplified = SimplifyDeep; + + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + }); +}); diff --git a/packages/1-framework/0-foundation/utils/tsdown.config.ts b/packages/1-framework/0-foundation/utils/tsdown.config.ts index 141a29e845..d5f8138889 100644 --- a/packages/1-framework/0-foundation/utils/tsdown.config.ts +++ b/packages/1-framework/0-foundation/utils/tsdown.config.ts @@ -8,5 +8,6 @@ export default defineConfig({ 'src/exports/defined.ts', 'src/exports/result.ts', 'src/exports/redact-db-url.ts', + 'src/exports/simplify-deep.ts', ], }); diff --git a/packages/1-framework/0-foundation/utils/vitest.config.ts b/packages/1-framework/0-foundation/utils/vitest.config.ts index cb29ab9f30..9392f4a6c6 100644 --- a/packages/1-framework/0-foundation/utils/vitest.config.ts +++ b/packages/1-framework/0-foundation/utils/vitest.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ globals: true, environment: 'node', include: ['test/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['test/**/*.test-d.ts'], + }, coverage: { provider: 'v8', reporter: ['text', 'lcov'], diff --git a/packages/2-mongo-family/5-query-builders/orm/package.json b/packages/2-mongo-family/5-query-builders/orm/package.json index 6d0eb76f7d..0366587cfe 100644 --- a/packages/2-mongo-family/5-query-builders/orm/package.json +++ b/packages/2-mongo-family/5-query-builders/orm/package.json @@ -16,10 +16,11 @@ }, "dependencies": { "@prisma-next/contract": "workspace:*", + "@prisma-next/framework-components": "workspace:*", "@prisma-next/mongo-contract": "workspace:*", "@prisma-next/mongo-query-ast": "workspace:*", - "@prisma-next/framework-components": "workspace:*", - "@prisma-next/mongo-value": "workspace:*" + "@prisma-next/mongo-value": "workspace:*", + "@prisma-next/utils": "workspace:*" }, "devDependencies": { "@prisma-next/adapter-mongo": "workspace:*", diff --git a/packages/2-mongo-family/5-query-builders/orm/src/exports/index.ts b/packages/2-mongo-family/5-query-builders/orm/src/exports/index.ts index e4d287db32..38b120f569 100644 --- a/packages/2-mongo-family/5-query-builders/orm/src/exports/index.ts +++ b/packages/2-mongo-family/5-query-builders/orm/src/exports/index.ts @@ -1,3 +1,4 @@ +export type { SimplifyDeep } from '@prisma-next/utils/simplify-deep'; export type { MongoCollection } from '../collection'; export { createMongoCollection } from '../collection'; export { compileMongoQuery } from '../compile'; @@ -27,7 +28,6 @@ export type { MongoWhereFilter, NoIncludes, ResolvedCreateInput, - SimplifyDeep, VariantCreateInput, VariantModelRow, VariantNames, diff --git a/packages/2-mongo-family/5-query-builders/orm/src/types.ts b/packages/2-mongo-family/5-query-builders/orm/src/types.ts index 95e360dde2..692d9299dd 100644 --- a/packages/2-mongo-family/5-query-builders/orm/src/types.ts +++ b/packages/2-mongo-family/5-query-builders/orm/src/types.ts @@ -11,16 +11,6 @@ import type { type Simplify = T extends unknown ? { [K in keyof T]: T[K] } : never; -export type SimplifyDeep = T extends readonly (infer E)[] - ? SimplifyDeep[] - : T extends Date | RegExp | ((...args: never[]) => unknown) - ? T - : T extends object - ? T extends unknown - ? { [K in keyof T]: SimplifyDeep } - : never - : T; - type ModelRelations< TContract extends MongoContract, ModelName extends string & keyof TContract['models'], diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index fc3b29ff68..3173e4c000 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -10,6 +10,7 @@ import { type ToWhereExpr, type WhereArg, } from '@prisma-next/sql-relational-core/ast'; +import type { SimplifyDeep } from '@prisma-next/utils/simplify-deep'; import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder'; import { normalizeAggregateResult } from './collection-aggregate-result'; import { mapCursorValuesToColumns, mapFieldsToColumns } from './collection-column-mapping'; @@ -159,7 +160,7 @@ interface MtiCreateContext { export class Collection< TContract extends Contract, ModelName extends string, - Row = InferRootRow, + Row = SimplifyDeep>, State extends CollectionTypeState = DefaultCollectionTypeState, > implements RowSelection { @@ -317,15 +318,17 @@ export class Collection< ): Collection< TContract, ModelName, - Row & { - [K in RelName]: IncludeRefinementValue< - TContract, - ModelName, - K, - DefaultModelRow, - RefinedResult - >; - }, + SimplifyDeep< + Row & { + [K in RelName]: IncludeRefinementValue< + TContract, + ModelName, + K, + DefaultModelRow, + RefinedResult + >; + } + >, State > { const relation = resolveIncludeRelation(this.contract, this.modelName, relationName as string); @@ -391,15 +394,17 @@ export class Collection< }; return this.#cloneWithRow< - Row & { - [K in RelName]: IncludeRefinementValue< - TContract, - ModelName, - K, - DefaultModelRow, - RefinedResult - >; - }, + SimplifyDeep< + Row & { + [K in RelName]: IncludeRefinementValue< + TContract, + ModelName, + K, + DefaultModelRow, + RefinedResult + >; + } + >, State >({ includes: [...this.state.includes, includeExpr], @@ -416,15 +421,19 @@ export class Collection< ): Collection< TContract, ModelName, - Pick, Fields[number]> & - IncludedRelationsForRow, + SimplifyDeep< + Pick, Fields[number]> & + IncludedRelationsForRow + >, State > { const selectedFields = mapFieldsToColumns(this.contract, this.modelName, fields); return this.#cloneWithRow< - Pick, Fields[number]> & - IncludedRelationsForRow, + SimplifyDeep< + Pick, Fields[number]> & + IncludedRelationsForRow + >, State >({ selectedFields, diff --git a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts index 4a23e5667c..d594ad6e8c 100644 --- a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts +++ b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts @@ -8,6 +8,7 @@ import { ColumnRef, LiteralExpr, } from '@prisma-next/sql-relational-core/ast'; +import type { SimplifyDeep } from '@prisma-next/utils/simplify-deep'; import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder'; import { getFieldToColumnMap } from './collection-contract'; import { mapStorageRowToModelFields } from './collection-runtime'; @@ -84,7 +85,11 @@ export class GroupedCollection< async aggregate( fn: (aggregate: AggregateBuilder) => Spec, ): Promise< - Array, GroupFields[number]> & AggregateResult> + Array< + SimplifyDeep< + Pick, GroupFields[number]> & AggregateResult + > + > > { const aggregateSpec = fn(createAggregateBuilder(this.contract, this.modelName)); const aggregateEntries = Object.entries(aggregateSpec); @@ -118,7 +123,9 @@ export class GroupedCollection< } return mapped; }) as Array< - Pick, GroupFields[number]> & AggregateResult + SimplifyDeep< + Pick, GroupFields[number]> & AggregateResult + > >; } } diff --git a/packages/3-extensions/sql-orm-client/test/simplify-deep.test-d.ts b/packages/3-extensions/sql-orm-client/test/simplify-deep.test-d.ts new file mode 100644 index 0000000000..b3ea919d0e --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/simplify-deep.test-d.ts @@ -0,0 +1,134 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import { Collection } from '../src/collection'; +import { createMockRuntime, getTestContext } from './helpers'; + +describe('Collection result types are simplified', () => { + const runtime = createMockRuntime(); + const context = getTestContext(); + + test('default Row is a plain object', () => { + const users = new Collection({ runtime, context }, 'User'); + type UserRow = Awaited>; + expectTypeOf>().toEqualTypeOf<{ + id: number; + name: string; + email: string; + invitedById: number | null; + address: { + readonly street: string; + readonly city: string; + readonly zip: string | null; + } | null; + }>(); + }); + + test('select() produces a plain object', () => { + const users = new Collection({ runtime, context }, 'User'); + const selected = users.select('id', 'email'); + type SelectedRow = Awaited>; + expectTypeOf>().toEqualTypeOf<{ + id: number; + email: string; + }>(); + }); + + test('include() produces a plain object with nested relation', () => { + const users = new Collection({ runtime, context }, 'User'); + const withPosts = users.include('posts'); + type WithPostsRow = Awaited>; + expectTypeOf>().toEqualTypeOf<{ + id: number; + name: string; + email: string; + invitedById: number | null; + address: { + readonly street: string; + readonly city: string; + readonly zip: string | null; + } | null; + posts: { + id: number; + title: string; + userId: number; + views: number; + embedding: number[] | null; + }[]; + }>(); + }); + + test('select().include() produces a plain object', () => { + const users = new Collection({ runtime, context }, 'User'); + const selected = users.select('name').include('posts'); + type Row = Awaited>; + expectTypeOf>().toEqualTypeOf<{ + name: string; + posts: { + id: number; + title: string; + userId: number; + views: number; + embedding: number[] | null; + }[]; + }>(); + }); + + test('include() with non-nullable to-one relation', () => { + const posts = new Collection({ runtime, context }, 'Post'); + const withAuthor = posts.include('author'); + type Row = Awaited>; + type AuthorField = NonNullable['author']; + expectTypeOf().toEqualTypeOf<{ + id: number; + name: string; + email: string; + invitedById: number | null; + address: { + readonly street: string; + readonly city: string; + readonly zip: string | null; + } | null; + }>(); + }); + + test('chained include() produces a plain object', () => { + const users = new Collection({ runtime, context }, 'User'); + const withPostsAndInviter = users.include('posts').include('invitedBy'); + type Row = Awaited>; + expectTypeOf>().toEqualTypeOf<{ + id: number; + name: string; + email: string; + invitedById: number | null; + address: { + readonly street: string; + readonly city: string; + readonly zip: string | null; + } | null; + posts: { + id: number; + title: string; + userId: number; + views: number; + embedding: number[] | null; + }[]; + invitedBy: { + id: number; + name: string; + email: string; + invitedById: number | null; + address: { + readonly street: string; + readonly city: string; + readonly zip: string | null; + } | null; + } | null; + }>(); + }); + + test('include() with count refinement', () => { + const users = new Collection({ runtime, context }, 'User'); + const withPostCount = users.include('posts', (posts) => posts.count()); + type Row = Awaited>; + expectTypeOf['posts']>().toEqualTypeOf(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd6e78b8b4..6ff5005dd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: '@prisma-next/mongo-runtime': specifier: workspace:* version: link:../../packages/2-mongo-family/7-runtime + '@prisma-next/utils': + specifier: workspace:* + version: link:../../packages/1-framework/0-foundation/utils mongodb: specifier: 'catalog:' version: 6.21.0 @@ -1192,6 +1195,9 @@ importers: '@prisma-next/mongo-value': specifier: workspace:* version: link:../../1-foundation/mongo-value + '@prisma-next/utils': + specifier: workspace:* + version: link:../../../1-framework/0-foundation/utils devDependencies: '@prisma-next/adapter-mongo': specifier: workspace:*