Skip to content
Open
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
4 changes: 2 additions & 2 deletions examples/mongo-demo/src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
--medium: #f59e0b;
--low: #22c55e;
--radius: 8px;
--font: 'Inter', system-ui, -apple-system, sans-serif;
--font: "Inter", system-ui, -apple-system, sans-serif;
}

* {
Expand Down Expand Up @@ -324,7 +324,7 @@ footer {
}

.legend li::before {
content: '→';
content: "→";
position: absolute;
left: 0;
color: var(--accent);
Expand Down
55 changes: 32 additions & 23 deletions packages/3-extensions/sql-orm-client/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import type {
RelatedModelName,
RelationNames,
ShorthandWhereFilter,
SimplifyDeep,
UniqueConstraintCriterion,
} from './types';
import { emptyState } from './types';
Expand Down Expand Up @@ -129,7 +130,7 @@ function isWhereDirectInput(value: unknown): value is WhereDirectInput {
export class Collection<
TContract extends SqlContract<SqlStorage>,
ModelName extends string,
Row = DefaultModelRow<TContract, ModelName>,
Row = SimplifyDeep<DefaultModelRow<TContract, ModelName>>,
State extends CollectionTypeState = DefaultCollectionTypeState,
Comment on lines 130 to 134
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F01 — Blocking: SimplifyDeep applied at every chaining step instead of terminal methods

The default Row is already SimplifyDeep<DefaultModelRow<...>>. When include() returns SimplifyDeep<Row & { posts: ... }>, this becomes SimplifyDeep<SimplifyDeep<...> & { ... }>. Each chaining step adds another wrapper. For users.select('name').include('posts'), that's three nested SimplifyDeep evaluations — O(N×K) work for N chained ops with K total keys.

Suggestion: Apply SimplifyDeep only at the terminal methods (first(), all(), toArray()) instead of at each builder step. Builder methods accumulate raw intersections internally; only the final result type gets simplified. This reduces overhead to exactly one pass regardless of chain length and is architecturally cleaner — simplification is a presentation concern, not a builder concern.

> implements RowSelection<Row>
{
Expand Down Expand Up @@ -231,15 +232,17 @@ export class Collection<
): Collection<
TContract,
ModelName,
Row & {
[K in RelName]: IncludeRefinementValue<
TContract,
ModelName,
K,
DefaultModelRow<TContract, RelatedName>,
RefinedResult
>;
},
SimplifyDeep<
Row & {
[K in RelName]: IncludeRefinementValue<
TContract,
ModelName,
K,
DefaultModelRow<TContract, RelatedName>,
RefinedResult
>;
}
>,
State
> {
const relation = resolveIncludeRelation(this.contract, this.modelName, relationName as string);
Expand Down Expand Up @@ -305,15 +308,17 @@ export class Collection<
};

return this.#cloneWithRow<
Row & {
[K in RelName]: IncludeRefinementValue<
TContract,
ModelName,
K,
DefaultModelRow<TContract, RelatedName>,
RefinedResult
>;
},
SimplifyDeep<
Row & {
[K in RelName]: IncludeRefinementValue<
TContract,
ModelName,
K,
DefaultModelRow<TContract, RelatedName>,
RefinedResult
>;
}
>,
State
>({
includes: [...this.state.includes, includeExpr],
Expand All @@ -330,15 +335,19 @@ export class Collection<
): Collection<
TContract,
ModelName,
Pick<DefaultModelRow<TContract, ModelName>, Fields[number]> &
IncludedRelationsForRow<TContract, ModelName, Row>,
SimplifyDeep<
Pick<DefaultModelRow<TContract, ModelName>, Fields[number]> &
IncludedRelationsForRow<TContract, ModelName, Row>
>,
State
> {
const selectedFields = mapFieldsToColumns(this.contract, this.modelName, fields);

return this.#cloneWithRow<
Pick<DefaultModelRow<TContract, ModelName>, Fields[number]> &
IncludedRelationsForRow<TContract, ModelName, Row>,
SimplifyDeep<
Pick<DefaultModelRow<TContract, ModelName>, Fields[number]> &
IncludedRelationsForRow<TContract, ModelName, Row>
>,
State
>({
selectedFields,
Expand Down
11 changes: 9 additions & 2 deletions packages/3-extensions/sql-orm-client/src/grouped-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
DefaultModelRow,
HavingBuilder,
HavingComparisonMethods,
SimplifyDeep,
} from './types';
import { combineWhereExprs } from './where-utils';

Expand Down Expand Up @@ -83,7 +84,11 @@ export class GroupedCollection<
async aggregate<Spec extends AggregateSpec>(
fn: (aggregate: AggregateBuilder<TContract, ModelName>) => Spec,
): Promise<
Array<Pick<DefaultModelRow<TContract, ModelName>, GroupFields[number]> & AggregateResult<Spec>>
Array<
SimplifyDeep<
Pick<DefaultModelRow<TContract, ModelName>, GroupFields[number]> & AggregateResult<Spec>
>
>
> {
const aggregateSpec = fn(createAggregateBuilder(this.contract, this.modelName));
const aggregateEntries = Object.entries(aggregateSpec);
Expand Down Expand Up @@ -117,7 +122,9 @@ export class GroupedCollection<
}
return mapped;
}) as Array<
Pick<DefaultModelRow<TContract, ModelName>, GroupFields[number]> & AggregateResult<Spec>
SimplifyDeep<
Pick<DefaultModelRow<TContract, ModelName>, GroupFields[number]> & AggregateResult<Spec>
>
>;
}
}
Expand Down
12 changes: 12 additions & 0 deletions packages/3-extensions/sql-orm-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-la
import type { ComputeColumnJsType } from '@prisma-next/sql-relational-core/types';
import type { RowSelection } from './collection-internal-types';

// ---------------------------------------------------------------------------
// SimplifyDeep — recursive type prettifier for IDE tooltips
// ---------------------------------------------------------------------------
Comment on lines +24 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F04 — Non-blocking: SimplifyDeep should be raised to @prisma-next/utils for cross-lane reuse

SimplifyDeep is a general-purpose type utility with no dependency on sql-orm-client internals. The @prisma-next/utils package (packages/1-framework/1-core/shared/utils) is the canonical home for shared utilities. Moving it there enables reuse by the Mongo family and other lanes.

Not blocking this PR, but should be done as a follow-up before other packages duplicate the type.


export type SimplifyDeep<T> = T extends readonly (infer Element)[]
? SimplifyDeep<Element>[]
Comment on lines +28 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F03 — Non-blocking: readonly arrays are silently converted to mutable arrays

The array branch matches readonly (infer Element)[] but returns SimplifyDeep<Element>[] (mutable). A readonly T[] input becomes T[]. Acceptable for current query result types (which are always mutable arrays), but subtly changes the type contract — relevant if SimplifyDeep is applied more broadly (see F04).

Suggestion: Either preserve readonly with an additional branch, or add a test documenting this intentional behavior:

test('readonly arrays become mutable (intentional)', () => {
  type Input = readonly ({ a: number } & { b: string })[];
  type Result = SimplifyDeep<Input>;
  expectTypeOf<Result>().toEqualTypeOf<{ a: number; b: string }[]>();
});

: T extends string | number | boolean | bigint | symbol | Date | Uint8Array
? T
: T extends object
? { [K in keyof T]: SimplifyDeep<T[K]> }
: T;
Comment on lines +28 to +34
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F05 — Non-blocking: Forward risk — polymorphic model compatibility

Polymorphic model support (ADR 173 — discriminator + variants) will be ported to the SQL domain and will impact this ORM client. Experience implementing a similar helper in the Mongo domain surfaced issues with polymorphic model type shapes.

SimplifyDeep distributes over simple unions correctly (SimplifyDeep<A | B> = SimplifyDeep<A> | SimplifyDeep<B>), but more complex polymorphic type algebra (variant-specific computed properties, conditional types) may not be preserved correctly by the { [K in keyof T]: ... } mapping.

Not a blocker — just flagging that when polymorphic models land in SQL ORM, this helper will need verification and may need extension or removal.


// ---------------------------------------------------------------------------
// Comparison / Filter / Order / Include
// ---------------------------------------------------------------------------
Expand Down
149 changes: 149 additions & 0 deletions packages/3-extensions/sql-orm-client/test/simplify-deep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expectTypeOf, test } from 'vitest';
import { Collection } from '../src/collection';
import type { SimplifyDeep } from '../src/types';
import { createMockRuntime, getTestContext } from './helpers';

describe('SimplifyDeep', () => {
test('primitives pass through', () => {
expectTypeOf<SimplifyDeep<string>>().toEqualTypeOf<string>();
expectTypeOf<SimplifyDeep<number>>().toEqualTypeOf<number>();
expectTypeOf<SimplifyDeep<boolean>>().toEqualTypeOf<boolean>();
expectTypeOf<SimplifyDeep<bigint>>().toEqualTypeOf<bigint>();
expectTypeOf<SimplifyDeep<symbol>>().toEqualTypeOf<symbol>();
expectTypeOf<SimplifyDeep<null>>().toEqualTypeOf<null>();
expectTypeOf<SimplifyDeep<undefined>>().toEqualTypeOf<undefined>();
expectTypeOf<SimplifyDeep<unknown>>().toEqualTypeOf<unknown>();
expectTypeOf<SimplifyDeep<never>>().toEqualTypeOf<never>();
});

test('branded primitives pass through', () => {
type Branded = string & { readonly __brand: true };
expectTypeOf<SimplifyDeep<Branded>>().toEqualTypeOf<Branded>();
});

test('Date and Uint8Array preserved', () => {
expectTypeOf<SimplifyDeep<Date>>().toEqualTypeOf<Date>();
expectTypeOf<SimplifyDeep<Uint8Array>>().toEqualTypeOf<Uint8Array>();
});

test('intersections flatten into plain objects', () => {
type Input = { a: number } & { b: string };
type Expected = { a: number; b: string };
expectTypeOf<SimplifyDeep<Input>>().toEqualTypeOf<Expected>();
});

test('arrays recurse', () => {
type Input = ({ a: number } & { b: string })[];
type Expected = { a: number; b: string }[];
expectTypeOf<SimplifyDeep<Input>>().toEqualTypeOf<Expected>();
});

test('nested objects recurse', () => {
type Input = { nested: { a: number } & { b: string } };
type Expected = { nested: { a: number; b: string } };
expectTypeOf<SimplifyDeep<Input>>().toEqualTypeOf<Expected>();
});

test('nullable objects', () => {
type Input = ({ a: number } & { b: string }) | null;
type Expected = { a: number; b: string } | null;
expectTypeOf<SimplifyDeep<Input>>().toEqualTypeOf<Expected>();
});

test('nested arrays of intersected objects', () => {
type Input = {
items: ({ id: number } & { name: string })[];
};
type Expected = {
items: { id: number; name: string }[];
};
expectTypeOf<SimplifyDeep<Input>>().toEqualTypeOf<Expected>();
});

test('bidirectional assignability for concrete types', () => {
type Original = { a: number } & { b: string; nested: { c: boolean } & { d: number } };
type Simplified = SimplifyDeep<Original>;

expectTypeOf<Original>().toExtend<Simplified>();
expectTypeOf<Simplified>().toExtend<Original>();
});
});

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<ReturnType<typeof users.first>>;
expectTypeOf<NonNullable<UserRow>>().toEqualTypeOf<{
id: number;
name: string;
email: string;
invitedById: number | null;
}>();
});

test('select() produces a plain object', () => {
const users = new Collection({ runtime, context }, 'User');
const selected = users.select('id', 'email');
type SelectedRow = Awaited<ReturnType<typeof selected.first>>;
expectTypeOf<NonNullable<SelectedRow>>().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<ReturnType<typeof withPosts.first>>;
expectTypeOf<NonNullable<WithPostsRow>>().toEqualTypeOf<{
id: number;
name: string;
email: string;
invitedById: number | null;
posts: {
id: number;
title: string;
userId: number;
views: number;
}[];
}>();
});

test('select().include() produces a plain object', () => {
const users = new Collection({ runtime, context }, 'User');
const selected = users.select('name').include('posts');
type Row = Awaited<ReturnType<typeof selected.first>>;
expectTypeOf<NonNullable<Row>>().toEqualTypeOf<{
name: string;
posts: {
id: number;
title: string;
userId: number;
views: number;
}[];
}>();
});

test('include() with non-nullable to-one relation', () => {
const posts = new Collection({ runtime, context }, 'Post');
const withAuthor = posts.include('author');
type Row = Awaited<ReturnType<typeof withAuthor.first>>;
type AuthorField = NonNullable<Row>['author'];
expectTypeOf<AuthorField>().toEqualTypeOf<{
id: number;
name: string;
email: string;
invitedById: number | null;
}>();
});

test('include() with count refinement', () => {
const users = new Collection({ runtime, context }, 'User');
const withPostCount = users.include('posts', (posts) => posts.count());
type Row = Awaited<ReturnType<typeof withPostCount.first>>;
expectTypeOf<NonNullable<Row>['posts']>().toEqualTypeOf<number>();
});
});
Comment on lines +72 to +149
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F02 — Blocking: No test coverage for multi-step chaining (the primary use case)

Deeply nested chains are the primary use case that motivated this change — they produce the most unreadable intersections in IDE tooltips. The tests here verify individual operations and one two-method chain (select().include()), but don't test deeper chains like include('posts').include('invitedBy').

Since each chaining step currently wraps the previous Row in a new SimplifyDeep<Row & ...>, these deeper chains exercise the idempotency property that unit-level tests don't cover.

Suggestion: Add at least one multi-include chain test:

test('chained include() produces a plain object', () => {
  const users = new Collection({ runtime, context }, 'User');
  const withPostsAndInviter = users.include('posts').include('invitedBy');
  type Row = Awaited<ReturnType<typeof withPostsAndInviter.first>>;
  expectTypeOf<NonNullable<Row>>().toEqualTypeOf<{
    id: number;
    name: string;
    email: string;
    invitedById: number | null;
    posts: { id: number; title: string; userId: number; views: number }[];
    invitedBy: { id: number; name: string; email: string; invitedById: number | null } | null;
  }>();
});

Loading