Skip to content

Commit fd50ce8

Browse files
Finished ORM
1 parent 21b04d6 commit fd50ce8

File tree

6 files changed

+230
-14
lines changed

6 files changed

+230
-14
lines changed

docs/pages/intermediate/databases-and-orms.md

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -709,14 +709,75 @@ See [**the unit tests in the repo**](https://github.com/CharlieDigital/typescrip
709709
<CodeSplitter>
710710
<template #left>
711711

712-
```ts
713-
// 🚧 WIP
712+
```ts{43,52,55-60}
713+
// 📄 results-repository.ts: Sample repository
714+
@Injectable()
715+
export class ResultsRepository {
716+
constructor(
717+
private readonly prismaService: PrismaService
718+
) {
719+
}
720+
721+
async top10FinishesByRunner(email: string) : Promise<RunnerRaceResult[]> {
722+
return (await this.prismaService.raceResult.findMany({
723+
select:{
724+
runner: { select: { name: true } },
725+
race: { select: { name: true, date: true } },
726+
position: true,
727+
time: true
728+
},
729+
where: {
730+
runner: { email: '[email protected]' },
731+
position: { lte: 10 }
732+
},
733+
orderBy: { position: 'asc' }
734+
})).map(r => ({
735+
runnerName: r.runner.name,
736+
raceName: r.race.name,
737+
position: r.position,
738+
time: r.time,
739+
raceDate: r.race.date
740+
}))
741+
}
742+
}
743+
744+
export type RunnerRaceResult = {
745+
runnerName: string,
746+
raceName: string,
747+
position: number,
748+
time: number,
749+
raceDate: Date
750+
}
751+
752+
// 📄 app.module.ts: Register DI
753+
@Module({
754+
imports: [],
755+
controllers: [AppController],
756+
providers: [AppService, PrismaService, ResultsRepository],
757+
})
758+
export class AppModule {}
759+
760+
// 📄 app.controller.ts: Add our endpoint and DI
761+
@Controller()
762+
export class AppController {
763+
constructor(
764+
private readonly appService: AppService,
765+
private readonly resultsRepository: ResultsRepository
766+
) {}
767+
768+
@Get('/top10/:email')
769+
async getTop10FinishesByRunner(
770+
@Param('email') email: string
771+
): Promise<RunnerRaceResult[]> {
772+
return await this.resultsRepository.top10FinishesByRunner(email)
773+
}
774+
}
714775
```
715776

716777
</template>
717778
<template #right>
718779

719-
```csharp{42,51,56-60}
780+
```csharp{42,50,55-61}
720781
// 📄 ResultsRepository.cs: Sample repository
721782
public class ResultsRepository(
722783
Database db // 👈 Injected via DI
@@ -729,7 +790,6 @@ public class ResultsRepository(
729790
.SelectMany(r => r.RaceResults!.Where(
730791
finish => finish.Position <= 10)
731792
)
732-
// ✨ Notice how everything is fully typed downstack
733793
.Select(finish => new {
734794
RunnerName = finish.Runner.Name,
735795
RaceName = finish.Race.Name,
@@ -773,7 +833,9 @@ public class AppController(
773833
public string Get() => "Hello, World!";
774834
775835
[HttpGet("/top10/{email}")]
776-
public async Task<List<RunnerRaceResult>> GetTop10FinishesByRunner(string email) {
836+
public async Task<
837+
List<RunnerRaceResult>
838+
> GetTop10FinishesByRunner(string email) {
777839
var results = await resultsRepository.Top10FinishesByRunner(email);
778840
return [.. results];
779841
}
@@ -783,15 +845,51 @@ public class AppController(
783845
</template>
784846
</CodeSplitter>
785847

848+
::: info An important distinction
849+
On the TypeScript side, the `.map` projection takes place on the *client side* after the result has been returned. On the .NET side, the projection and aliasing takes place *on the database server*; the first `.Select()` expression is transformed into a SQL `SELECT field AS alias, ...` whereas the second `Select()` materializes it into the record.
850+
:::
851+
786852
## Hoisting Navigations
787853

788854
EF Core will attempt to persist the entire object tree if you round-trip the entity. To prevent this -- for example, we only want to round-trip the runner -- we can use a simple technique here to split out the navigation collections from the results:
789855

790856
<CodeSplitter>
791857
<template #left>
792858

793-
```ts
794-
// 🚧 WIP
859+
```ts{18,19}
860+
// 📄 results-repository.ts: Retrieve a runner and her results
861+
async runnerResults(email: string) { // [!code warning] Only a shape here, not a type
862+
return await this.prismaService.runner.findFirst({
863+
where: {
864+
865+
},
866+
include: {
867+
races: { // 👈 Included
868+
include: { race: true } // 👈 Included
869+
}
870+
}
871+
})
872+
}
873+
874+
// We "hoist" our dependent properties here.
875+
type RunnerResults = {
876+
runner: Runner,
877+
results: RaceResult[], // 👈 We'll hoist includes here
878+
races: Race[] // 👈 We'll hoist includes here
879+
}
880+
881+
// 📄 app.controller.cs: Endpoint for runner and results
882+
@Get('/results/:email')
883+
async getRunnerResult (
884+
@Param('email') email: string
885+
) : Promise<RunnerResults> {
886+
const result = await this.resultsRepository.runnerResults(email)
887+
return {
888+
runner: result, // [!code warning] Requires manual trimming
889+
results: result.races, // [!code warning] Requires manual trimming
890+
races: result.races.flatMap(r => r.race) // [!code warning] Requires manual trimming
891+
}
892+
}
795893
```
796894

797895
</template>
@@ -829,6 +927,8 @@ public async Task<RunnerResults> GetRunnerResults(string email) {
829927

830928
Remember how we used `[JsonIgnore]` in our model? This means that at serialization at the boundary, `Runner.Races` and `Runner.RaceResults` will automatically be stripped out (nice)! So to keep them in the output JSON, we need to "hoist" them up into a "DTO" record.
831929

930+
On the Prisma side, it's not so easy. We'll either have to do a deeper selection or explicitly delete fields off of our objects to ensure they do not round trip. It's possible to use [lodash's `omit`](https://www.geeksforgeeks.org/lodash-_-omit-method/) or JavaScript `delete` to remove the navigations manually.
931+
832932
::: tip
833933
This is an extremely useful pattern and should generally be used for all navigation properties as it will allow round-tripping the entity for updates without passing the navigations along.
834934
:::

src/typescript/prisma-api/src/app.controller.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { AppController } from './app.controller';
33
import { AppService } from './app.service';
4+
import { PrismaService } from './prisma.service';
5+
import { ResultsRepository } from './results-repository';
46

57
describe('AppController', () => {
68
let appController: AppController;
79

810
beforeEach(async () => {
911
const app: TestingModule = await Test.createTestingModule({
1012
controllers: [AppController],
11-
providers: [AppService],
13+
providers: [AppService, PrismaService, ResultsRepository],
1214
}).compile();
1315

1416
appController = app.get<AppController>(AppController);
1517
});
1618

19+
test('placeholder', () => {
20+
// Just a placeholder
21+
})
22+
1723
// describe('root', () => {
1824
// it('should return "Hello World!"', () => {
1925
// expect(appController.getHello()).toBe('Hello World!');
Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,37 @@
1-
import { Controller, Get } from '@nestjs/common';
1+
import { Controller, Get, Param } from '@nestjs/common';
22
import { AppService } from './app.service';
3+
import { RaceResultDto as RunnerRaceResult, ResultsRepository } from './results-repository';
4+
import { Race, RaceResult, Runner } from '@prisma/client';
35

46
@Controller()
57
export class AppController {
6-
constructor(private readonly appService: AppService) {}
8+
constructor(
9+
private readonly appService: AppService,
10+
private readonly resultsRepository: ResultsRepository
11+
) {}
712

8-
@Get()
9-
getHello(): string {
10-
return this.appService.getHello();
13+
@Get('/top10/:email')
14+
async getTop10FinishesByRunner(
15+
@Param('email') email: string
16+
): Promise<RunnerRaceResult[]> {
17+
return await this.resultsRepository.top10FinishesByRunner(email)
1118
}
19+
20+
@Get('/results/:email')
21+
async getRunnerResult (
22+
@Param('email') email: string
23+
) : Promise<RunnerResults> {
24+
const result = await this.resultsRepository.runnerResults(email)
25+
return {
26+
runner: result,
27+
results: result.races,
28+
races: result.races.flatMap(r => r.race)
29+
}
30+
}
31+
}
32+
33+
type RunnerResults = {
34+
runner: Runner,
35+
results: RaceResult[],
36+
races: Race[]
1237
}

src/typescript/prisma-api/src/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
22
import { AppController } from './app.controller';
33
import { AppService } from './app.service';
44
import { PrismaService } from './prisma.service';
5+
import { ResultsRepository } from './results-repository';
56

67
@Module({
78
imports: [],
89
controllers: [AppController],
9-
providers: [AppService, PrismaService],
10+
providers: [AppService, PrismaService, ResultsRepository],
1011
})
1112
export class AppModule {}

src/typescript/prisma-api/src/prisma.service.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,42 @@ describe('Database access', () => {
261261
console.log(loadedAdasTop10Races)
262262

263263
expect(loadedAdasTop10Races.length).toBe(2)
264+
265+
const loadedAdasTop10RacesFlat = (await tx.raceResult.findMany({
266+
select:{
267+
runner: { select: { name: true } },
268+
race: { select: { name: true, date: true } },
269+
position: true,
270+
time: true
271+
},
272+
where: {
273+
runner: { email: '[email protected]' },
274+
position: { lte: 10 }
275+
},
276+
orderBy: { position: 'asc' }
277+
})).map(r => ({
278+
runnerName: r.runner.name,
279+
raceName: r.race.name,
280+
position: r.position,
281+
time: r.time,
282+
raceDate: r.race.date
283+
}))
284+
285+
const loadedRunnerResults = await tx.runner.findFirst({
286+
where: {
287+
288+
},
289+
include: {
290+
races: {
291+
include: {
292+
race: true
293+
}
294+
}
295+
}
296+
})
297+
298+
expect(loadedRunnerResults.races.length).toBe(3)
299+
expect(loadedRunnerResults.races[0].race).toBeDefined()
264300
})
265301
})
266302
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Injectable } from "@nestjs/common";
2+
import { PrismaService } from "./prisma.service";
3+
4+
@Injectable()
5+
export class ResultsRepository {
6+
constructor(private readonly prismaService: PrismaService) {}
7+
8+
async top10FinishesByRunner(email: string) : Promise<RaceResultDto[]> {
9+
return (await this.prismaService.raceResult.findMany({
10+
select:{
11+
runner: { select: { name: true } },
12+
race: { select: { name: true, date: true } },
13+
position: true,
14+
time: true
15+
},
16+
where: {
17+
runner: { email: '[email protected]' },
18+
position: { lte: 10 }
19+
},
20+
orderBy: { position: 'asc' }
21+
})).map(r => ({
22+
runnerName: r.runner.name,
23+
raceName: r.race.name,
24+
position: r.position,
25+
time: r.time,
26+
raceDate: r.race.date
27+
}))
28+
}
29+
30+
async runnerResults(email: string) {
31+
return await this.prismaService.runner.findFirst({
32+
where: {
33+
34+
},
35+
include: {
36+
races: { include: { race: true } }
37+
}
38+
})
39+
}
40+
}
41+
42+
export type RaceResultDto = {
43+
runnerName: string,
44+
raceName: string,
45+
position: number,
46+
time: number,
47+
raceDate: Date
48+
}

0 commit comments

Comments
 (0)