-
Notifications
You must be signed in to change notification settings - Fork 15
Mongo ORM: extendable Collection class + collections registry (ADR 175) #936
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
0d16b5f
254a589
6ca6542
8b8d04b
93f2d5f
eee0284
314fab7
99333c2
5f88ae5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,36 +9,70 @@ import type { MongoCollection } from './collection'; | |
| import { createMongoCollection } from './collection'; | ||
| import type { MongoQueryExecutor } from './executor'; | ||
|
|
||
| export interface MongoOrmOptions<TContract extends MongoContract> { | ||
| export type AnyMongoCollectionClass = new (...args: never[]) => object; | ||
|
|
||
| export interface MongoOrmOptions< | ||
| TContract extends MongoContract, | ||
| Collections extends Partial<Record<string, AnyMongoCollectionClass>> = Record<never, never>, | ||
|
Comment on lines
+22
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts --items all
rg -n -C3 'AnyMongoCollectionClass|CustomCollectionForModel|instantiateCustomCollection|new \(\.\.\.args: never\[\]' \
packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts
rg -n -C3 'class .*Repository|collections:|expect.*Assignable|expect.*Not' \
packages/2-mongo-family/5-query-builders/orm/test/custom-collection.test-d.ts \
packages/2-mongo-family/5-query-builders/orm/test/custom-collection.test.tsRepository: prisma/prisma-next Length of output: 19094 🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline packages/2-mongo-family/5-query-builders/orm/src/collection.ts --items all
sed -n '1,220p' packages/2-mongo-family/5-query-builders/orm/src/collection.ts | cat -n
printf '\n--- mongo-orm.ts ---\n'
sed -n '1,220p' packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts | cat -n
printf '\n--- custom-collection.test-d.ts ---\n'
sed -n '1,220p' packages/2-mongo-family/5-query-builders/orm/test/custom-collection.test-d.ts | cat -nRepository: prisma/prisma-next Length of output: 23190 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts')
text = p.read_text()
for needle in [
'type CustomCollectionForModel',
'function instantiateCustomCollection',
'validateCollectionRegistry(',
'new (...args: never[])',
]:
print(f'=== {needle} ===')
idx = text.find(needle)
if idx != -1:
start = max(0, text.rfind('\n', 0, idx-120))
end = min(len(text), text.find('\n\n', idx+1))
print(text[start:end])
print()
else:
print('not found')
PYRepository: prisma/prisma-next Length of output: 1782 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf 'collection.ts outline\n'
ast-grep outline packages/2-mongo-family/5-query-builders/orm/src/collection.ts --items all
printf '\ncollection.ts\n'
sed -n '1,240p' packages/2-mongo-family/5-query-builders/orm/src/collection.ts | cat -n
printf '\nmongo-orm.ts\n'
sed -n '1,220p' packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts | cat -n
printf '\ncustom-collection.test-d.ts\n'
sed -n '1,220p' packages/2-mongo-family/5-query-builders/orm/test/custom-collection.test-d.ts | cat -nRepository: prisma/prisma-next Length of output: 24190 🏁 Script executed: #!/bin/bash
set -euo pipefail
log=/tmp/coderabbit-shell-logs/shell-output-jqQ52x
sed -n '1,220p' "$log" | cat -nRepository: prisma/prisma-next Length of output: 19119 Encode the registry constructor and model types.
🤖 Prompt for AI Agents |
||
| > { | ||
| readonly contract: TContract; | ||
| readonly executor: MongoQueryExecutor; | ||
| /** | ||
| * Custom `Collection` subclasses keyed by model name (not root/collection name), | ||
| * mirroring the SQL ORM's `orm({ collections })`. Roots whose model has no entry | ||
| * get the base collection. | ||
| */ | ||
| readonly collections?: Collections; | ||
| } | ||
|
|
||
| type CustomCollectionForModel< | ||
| Collections extends Partial<Record<string, AnyMongoCollectionClass>>, | ||
| ModelName extends string, | ||
| > = ModelName extends keyof Collections | ||
| ? Collections[ModelName] extends AnyMongoCollectionClass | ||
| ? InstanceType<Collections[ModelName]> | ||
| : never | ||
| : never; | ||
|
|
||
| type RootCollection< | ||
| TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>, | ||
| Collections extends Partial<Record<string, AnyMongoCollectionClass>>, | ||
| RootName extends keyof TContract['roots'] & string, | ||
| > = [CustomCollectionForModel<Collections, RootModelName<TContract, RootName>>] extends [never] | ||
| ? MongoCollection<TContract, RootModelName<TContract, RootName>> | ||
| : CustomCollectionForModel<Collections, RootModelName<TContract, RootName>>; | ||
|
|
||
| export type MongoOrmClient< | ||
| TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>, | ||
| Collections extends Partial<Record<string, AnyMongoCollectionClass>> = Record<never, never>, | ||
| > = { | ||
| readonly [K in keyof TContract['roots'] & string]: MongoCollection< | ||
| TContract, | ||
| RootModelName<TContract, K> | ||
| >; | ||
| readonly [K in keyof TContract['roots'] & string]: RootCollection<TContract, Collections, K>; | ||
| }; | ||
|
|
||
| export function mongoOrm< | ||
| TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>, | ||
| >(options: MongoOrmOptions<TContract>): MongoOrmClient<TContract> { | ||
| const { contract, executor } = options; | ||
| Collections extends Partial<Record<string, AnyMongoCollectionClass>> = Record<never, never>, | ||
| >(options: MongoOrmOptions<TContract, Collections>): MongoOrmClient<TContract, Collections> { | ||
| const { contract, executor, collections } = options; | ||
| const client: Record<string, unknown> = {}; | ||
|
|
||
| for (const [rootName, rootRef] of Object.entries(contract.roots)) { | ||
| client[rootName] = createMongoCollection( | ||
| contract, | ||
| blindCast< | ||
| RootModelName<TContract, typeof rootName & keyof TContract['roots'] & string>, | ||
| 'roots entries are CrossReferences; rootRef.model is a valid RootModelName for this contract' | ||
| >(rootRef.model), | ||
| executor, | ||
| ); | ||
| const modelName = blindCast< | ||
| RootModelName<TContract, typeof rootName & keyof TContract['roots'] & string>, | ||
| 'roots entries are CrossReferences; rootRef.model is a valid RootModelName for this contract' | ||
| >(rootRef.model); | ||
| const CustomCtor = collections?.[rootRef.model]; | ||
| client[rootName] = CustomCtor | ||
| ? new (blindCast< | ||
| new ( | ||
| contract: TContract, | ||
| modelName: string, | ||
| executor: MongoQueryExecutor, | ||
| ) => object, | ||
| 'a registered collection class is a Collection subclass constructor' | ||
| >(CustomCtor))(contract, modelName, executor) | ||
| : createMongoCollection(contract, modelName, executor); | ||
| } | ||
|
|
||
| return client as MongoOrmClient<TContract>; | ||
| return client as MongoOrmClient<TContract, Collections>; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { describe, expectTypeOf, it } from 'vitest'; | ||
| import type { Contract } from '../../../1-foundation/mongo-contract/test/fixtures/orm-contract'; | ||
| import ormContractJson from '../../../1-foundation/mongo-contract/test/fixtures/orm-contract.json'; | ||
| import type { MongoCollection } from '../src/collection'; | ||
| import { Collection } from '../src/collection'; | ||
| import type { MongoQueryExecutor } from '../src/executor'; | ||
| import { mongoOrm } from '../src/mongo-orm'; | ||
|
|
||
| const contract = ormContractJson as unknown as Contract; | ||
| declare const executor: MongoQueryExecutor; | ||
|
|
||
| class UserRepository extends Collection<Contract, 'User'> { | ||
| byName(name: string) { | ||
| return this.where({ name }); | ||
| } | ||
| } | ||
|
|
||
| describe('custom collection typing', () => { | ||
| it('a registered root is typed as the subclass', () => { | ||
| const client = mongoOrm({ contract, executor, collections: { User: UserRepository } }); | ||
| expectTypeOf(client.users).toEqualTypeOf<UserRepository>(); | ||
| expectTypeOf(client.users.byName).toBeFunction(); | ||
| }); | ||
|
|
||
| it('unregistered roots keep the base collection type', () => { | ||
| const client = mongoOrm({ contract, executor, collections: { User: UserRepository } }); | ||
| expectTypeOf(client.tasks).toEqualTypeOf<MongoCollection<Contract, 'Task'>>(); | ||
| }); | ||
|
|
||
| it('no collections option keeps every root on the base type', () => { | ||
| const client = mongoOrm({ contract, executor }); | ||
| expectTypeOf(client.users).toEqualTypeOf<MongoCollection<Contract, 'User'>>(); | ||
| }); | ||
|
|
||
| it('domain methods return a chainable collection', async () => { | ||
| const client = mongoOrm({ contract, executor, collections: { User: UserRepository } }); | ||
| const first = await client.users.byName('Alice').take(1).first(); | ||
| expectTypeOf(first).not.toBeAny(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; | ||
| import type { | ||
| MongoMatchStage, | ||
| MongoQueryPlan, | ||
| MongoSortStage, | ||
| } from '@prisma-next/mongo-query-ast/execution'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import type { Contract } from '../../../1-foundation/mongo-contract/test/fixtures/orm-contract'; | ||
| import ormContractJson from '../../../1-foundation/mongo-contract/test/fixtures/orm-contract.json'; | ||
| import { Collection, createMongoCollection } from '../src/collection'; | ||
| import type { MongoQueryExecutor } from '../src/executor'; | ||
| import { mongoOrm } from '../src/mongo-orm'; | ||
|
|
||
| const contract = ormContractJson as unknown as Contract; | ||
|
|
||
| function createMockExecutor(...responses: unknown[][]): MongoQueryExecutor & { | ||
| lastPlan: MongoQueryPlan | undefined; | ||
| readonly lastStages: ReadonlyArray<unknown> | undefined; | ||
| } { | ||
| let callIndex = 0; | ||
| const mock = { | ||
| lastPlan: undefined as MongoQueryPlan | undefined, | ||
| get lastStages(): ReadonlyArray<unknown> | undefined { | ||
| const cmd = mock.lastPlan?.command; | ||
| if (cmd?.kind === 'aggregate') return cmd.pipeline as ReadonlyArray<unknown>; | ||
| return undefined; | ||
| }, | ||
| execute<Row>(plan: MongoQueryPlan<Row>): AsyncIterableResult<Row> { | ||
| mock.lastPlan = plan as MongoQueryPlan; | ||
| const data = responses[callIndex] ?? []; | ||
| callIndex++; | ||
| async function* gen(): AsyncGenerator<Row> { | ||
| for (const row of data) yield row as Row; | ||
| } | ||
| return new AsyncIterableResult(gen()); | ||
| }, | ||
| }; | ||
| return mock; | ||
| } | ||
|
|
||
| class UserRepository extends Collection<Contract, 'User'> { | ||
| byName(name: string) { | ||
| return this.where({ name }); | ||
| } | ||
|
|
||
| newestFirst() { | ||
| return this.orderBy({ _id: -1 }); | ||
| } | ||
| } | ||
|
|
||
| class TaskRepository extends Collection<Contract, 'Task'> { | ||
| bugs() { | ||
| return this.variant('Bug'); | ||
| } | ||
| } | ||
|
|
||
| describe('custom collection subclasses', () => { | ||
| it('mongoOrm exposes a registered subclass on its root', () => { | ||
| const executor = createMockExecutor(); | ||
| const client = mongoOrm({ contract, executor, collections: { User: UserRepository } }); | ||
| expect(client.users).toBeInstanceOf(UserRepository); | ||
| }); | ||
|
|
||
| it('roots without a registered subclass get the base collection', () => { | ||
| const executor = createMockExecutor(); | ||
| const client = mongoOrm({ contract, executor, collections: { User: UserRepository } }); | ||
| expect(client.tasks).toBeInstanceOf(Collection); | ||
| expect(client.tasks).not.toBeInstanceOf(UserRepository); | ||
| }); | ||
|
|
||
| it('a domain method compiles the same plan as the base collection', async () => { | ||
| const repoExecutor = createMockExecutor([]); | ||
| const baseExecutor = createMockExecutor([]); | ||
| const client = mongoOrm({ | ||
| contract, | ||
| executor: repoExecutor, | ||
| collections: { User: UserRepository }, | ||
| }); | ||
| const base = createMongoCollection(contract, 'User', baseExecutor); | ||
|
|
||
| for await (const _row of (client.users as UserRepository).byName('Alice').all()) { | ||
| // drain | ||
| } | ||
| for await (const _row of base.where({ name: 'Alice' }).all()) { | ||
| // drain | ||
| } | ||
|
|
||
| const repoMatch = repoExecutor.lastStages?.[0] as MongoMatchStage; | ||
| const baseMatch = baseExecutor.lastStages?.[0] as MongoMatchStage; | ||
| expect(repoMatch).toEqual(baseMatch); | ||
| }); | ||
|
|
||
| it('chaining preserves the subclass', () => { | ||
| const executor = createMockExecutor(); | ||
| const client = mongoOrm({ contract, executor, collections: { User: UserRepository } }); | ||
| const users = client.users as UserRepository; | ||
| expect(users.byName('Alice')).toBeInstanceOf(UserRepository); | ||
| expect(users.newestFirst()).toBeInstanceOf(UserRepository); | ||
| expect(users.byName('Alice').take(5).skip(1).select('name')).toBeInstanceOf(UserRepository); | ||
| }); | ||
|
|
||
| it('variant() preserves the subclass', () => { | ||
| const executor = createMockExecutor(); | ||
| const client = mongoOrm({ contract, executor, collections: { Task: TaskRepository } }); | ||
| const tasks = client.tasks as TaskRepository; | ||
| expect(tasks.bugs()).toBeInstanceOf(TaskRepository); | ||
| }); | ||
|
|
||
| it('a directly constructed subclass executes queries', async () => { | ||
| const executor = createMockExecutor([{ _id: '1', name: 'Alice' }]); | ||
| const repo = new UserRepository(contract, 'User', executor); | ||
| const row = await repo.byName('Alice').first(); | ||
| expect(row).toEqual({ _id: '1', name: 'Alice' }); | ||
| }); | ||
|
|
||
| it('domain methods compose with base chaining before execution', async () => { | ||
| const executor = createMockExecutor([]); | ||
| const client = mongoOrm({ contract, executor, collections: { User: UserRepository } }); | ||
| const users = client.users as UserRepository; | ||
| for await (const _row of users.byName('Alice').orderBy({ name: 1 }).take(2).all()) { | ||
| // drain | ||
| } | ||
| const stages = executor.lastStages as Array<MongoMatchStage | MongoSortStage>; | ||
| expect(stages.map((s) => (s as { kind: string }).kind).slice(0, 3)).toEqual([ | ||
| 'match', | ||
| 'sort', | ||
| 'limit', | ||
| ]); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.