Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Repository Pattern - organizacja skarbca

doświadczeni zarządcy! Konsul Caesar.js zauważył, że nasza legion coraz lepiej radzi sobie z zarządzaniem relacjami między danymi. Teraz czas nauczyć się Repository Pattern - najlepszych praktyk organizacji dostępu do naszej rzymskiego skarbca!

Czym jest Repository Pattern?

Repository Pattern to jak zatrudnienie najlepszego zarządcę w aplikacji - osoby, która dokładnie wie gdzie leży każdy tribut, jak go bezpiecznie pobrać i gdzie go umieścić. Zamiast bezpośrednio grzebać w skarbca (bazie danych), wszyscy członkowie legionu proszą zarządcę o to, czego potrzebują.

Zalety Repository Pattern:

  • Centralizacja logiki dostępu - cały kod dotyczący tributów w jednym miejscu
  • Łatwiejsze testowanie - można zasymulować zarządcę bez prawdziwej skarbca
  • Czytelność kodu - jasne metody biznesowe zamiast surowych zapytań SQL
  • Możliwość zmiany bazy - zarządca może przejść na inny rodzaj skarbca

Podstawowy Repository w TypeORM

1// tribute.repository.ts
2import { Repository, EntityRepository } from 'typeorm';
3import { Tribute } from './tribute.entity';
4
5@EntityRepository(Tribute)
6export class TributeRepository extends Repository<Tribute> {
7 // Znajdź tributy według typu
8 async findByType(type: string): Promise<Tribute[]> {
9 return this.find({
10 where: { type },
11 order: { collectedAt: 'DESC' }
12 });
13 }
14
15 // Znajdź najcenniejsze tributy
16 async findMostValuable(limit: number = 10): Promise<Tribute[]> {
17 return this.find({
18 order: { value: 'DESC' },
19 take: limit
20 });
21 }
22
23 // Znajdź tributy na konkretnej wyspie
24 async findByLocation(location: string): Promise<Tribute[]> {
25 return this.createQueryBuilder('tribute')
26 .where('tribute.location ILIKE :location', { location: `%${location}%` })
27 .orderBy('tribute.value', 'DESC')
28 .getMany();
29 }
30
31 // Oblicz całkowitą wartość tributów
32 async getTotalValue(): Promise<number> {
33 const result = await this.createQueryBuilder('tribute')
34 .select('SUM(tribute.value)', 'total')
35 .getRawOne();
36 
37 return parseFloat(result.total) || 0;
38 }
39
40 // Znajdź tributy w określonym przedziale wartości
41 async findByValueRange(minValue: number, maxValue: number): Promise<Tribute[]> {
42 return this.createQueryBuilder('tribute')
43 .where('tribute.value BETWEEN :minValue AND :maxValue', {
44 minValue,
45 maxValue
46 })
47 .orderBy('tribute.value', 'ASC')
48 .getMany();
49 }
50}

Custom Repository dla Legionariuszy

1// legionary.repository.ts
2import { Repository, EntityRepository, SelectQueryBuilder } from 'typeorm';
3import { Legionary } from './legionary.entity';
4import { Centurion } from './centurion.entity';
5
6@EntityRepository(Legionary)
7export class LegionaryRepository extends Repository<Legionary> {
8 // Znajdź legionariuszy według rangi
9 async findByRank(rank: string): Promise<Legionary[]> {
10 return this.find({
11 where: { rank },
12 relations: ['centurion', 'tributeMap'],
13 order: { tributeCount: 'DESC' }
14 });
15 }
16
17 // Znajdź legionariuszy z określoną umiejętnością
18 async findBySkill(skillName: string, minLevel: number = 1): Promise<Legionary[]> {
19 return this.createQueryBuilder('legionary')
20 .leftJoinAndSelect('legionary.legionarySkills', 'legionarySkill')
21 .leftJoinAndSelect('legionarySkill.skill', 'skill')
22 .leftJoinAndSelect('legionary.centurion', 'centurion')
23 .where('skill.name = :skillName', { skillName })
24 .andWhere('legionarySkill.level >= :minLevel', { minLevel })
25 .orderBy('legionarySkill.level', 'DESC')
26 .getMany();
27 }
28
29 // Znajdź aktywnych legionariuszy bez centuriona
30 async findActiveWithoutCenturion(): Promise<Legionary[]> {
31 return this.find({
32 where: {
33 isActive: true,
34 centurion: null
35 },
36 order: { tributeCount: 'DESC' }
37 });
38 }
39
40 // Wyszukiwanie zaawansowane
41 async searchLegionarys(searchTerm: string): Promise<Legionary[]> {
42 return this.createQueryBuilder('legionary')
43 .leftJoinAndSelect('legionary.centurion', 'centurion')
44 .where('legionary.name ILIKE :search', { search: `%${searchTerm}%` })
45 .orWhere('legionary.cohort ILIKE :search', { search: `%${searchTerm}%` })
46 .orWhere('centurion.name ILIKE :search', { search: `%${searchTerm}%` })
47 .orderBy('legionary.name', 'ASC')
48 .getMany();
49 }
50
51 // Statystyki legionariuszy
52 async getLegionaryStatistics() {
53 const stats = await this.createQueryBuilder('legionary')
54 .select([
55 'COUNT(*) as totalLegionaries',
56 'AVG(legionary.tributeCount) as avgTributes',
57 'SUM(legionary.totalTributeValue) as totalValue',
58 'MAX(legionary.tributeCount) as maxTributes',
59 'COUNT(CASE WHEN legionary.isActive = true THEN 1 END) as activeLegionaries'
60 ])
61 .getRawOne();
62 
63 return {
64 totalLegionaries: parseInt(stats.totallegionarys),
65 avgTributes: parseFloat(stats.avgtributes) || 0,
66 totalValue: parseFloat(stats.totalvalue) || 0,
67 maxTributes: parseInt(stats.maxtributes) || 0,
68 activeLegionaries: parseInt(stats.activelegionarys)
69 };
70 }
71
72 // Znajdź legionariuszy, którzy nie byli widziani od określonego czasu
73 async findInactive(daysAgo: number): Promise<Legionary[]> {
74 const cutoffDate = new Date();
75 cutoffDate.setDate(cutoffDate.getDate() - daysAgo);
76
77 return this.createQueryBuilder('legionary')
78 .where('legionary.lastSeenAt < :cutoffDate', { cutoffDate })
79 .orWhere('legionary.lastSeenAt IS NULL')
80 .orderBy('legionary.lastSeenAt', 'ASC')
81 .getMany();
82 }
83}

Repository dla Centurionów

1// centurion.repository.ts
2import { Repository, EntityRepository } from 'typeorm';
3import { Centurion } from './centurion.entity';
4
5@EntityRepository(Centurion)
6export class CenturionRepository extends Repository<Centurion> {
7 // Znajdź centurionów z największym legionem
8 async findByLegionSize(minLegionSize: number = 5): Promise<Centurion[]> {
9 return this.createQueryBuilder('centurion')
10 .leftJoinAndSelect('centurion.cohort', 'legionary')
11 .having('COUNT(legionary.id) >= :minLegionSize', { minLegionSize })
12 .groupBy('centurion.id')
13 .orderBy('COUNT(legionary.id)', 'DESC')
14 .getMany();
15 }
16
17 // Ranking centurionów według wartości tributów
18 async getRankingByTributeValue(): Promise<any[]> {
19 return this.createQueryBuilder('centurion')
20 .leftJoin('centurion.cohort', 'legionary')
21 .select([
22 'centurion.id as centurionId',
23 'centurion.name as centurionName',
24 'centurion.cohortName as cohortName',
25 'COUNT(legionary.id) as cohortSize',
26 'SUM(legionary.totalTributeValue) as totalLegionValue',
27 'centurion.totalTributeValue as centurionValue',
28 '(centurion.totalTributeValue + COALESCE(SUM(legionary.totalTributeValue), 0)) as combinedValue'
29 ])
30 .groupBy('centurion.id')
31 .orderBy('combinedValue', 'DESC')
32 .getRawMany();
33 }
34
35 // Znajdź centurionów z doświadczeniem w określonym zakresie
36 async findByExperience(minYears: number, maxYears: number): Promise<Centurion[]> {
37 return this.find({
38 where: {
39 yearsOfExperience: MoreThanOrEqual(minYears) && LessThanOrEqual(maxYears)
40 },
41 relations: ['cohort'],
42 order: { yearsOfExperience: 'DESC' }
43 });
44 }
45
46 // Znajdź centurionów bez legionu
47 async findWithoutLegion(): Promise<Centurion[]> {
48 return this.createQueryBuilder('centurion')
49 .leftJoin('centurion.cohort', 'legionary')
50 .where('legionary.id IS NULL')
51 .orderBy('centurion.yearsOfExperience', 'DESC')
52 .getMany();
53 }
54}

Abstrakcyjny Base Repository

1// base.repository.ts
2import { Repository, FindOptionsWhere, FindManyOptions } from 'typeorm';
3
4export abstract class BaseRepository<T> extends Repository<T> {
5 // Standardowe metody dla wszystkich repositoriów
6 
7 async findByIds(ids: number[]): Promise<T[]> {
8 if (ids.length === 0) return [];
9 
10 return this.createQueryBuilder('entity')
11 .where('entity.id IN (:...ids)', { ids })
12 .getMany();
13 }
14
15 async existsById(id: number): Promise<boolean> {
16 const count = await this.count({ where: { id } as FindOptionsWhere<T> });
17 return count > 0;
18 }
19
20 async findWithPagination(
21 page: number = 1,
22 limit: number = 10,
23 options?: FindManyOptions<T>
24 ) {
25 const [items, total] = await this.findAndCount({
26 ...options,
27 skip: (page - 1) * limit,
28 take: limit
29 });
30
31 return {
32 items,
33 pagination: {
34 page,
35 limit,
36 total,
37 totalPages: Math.ceil(total / limit),
38 hasNext: page * limit < total,
39 hasPrev: page > 1
40 }
41 };
42 }
43
44 async batchInsert(entities: Partial<T>[], batchSize: number = 100): Promise<T[]> {
45 const results: T[] = [];
46 
47 for (let i = 0; i < entities.length; i += batchSize) {
48 const batch = entities.slice(i, i + batchSize);
49 const inserted = await this.save(batch as any);
50 results.push(...inserted);
51 }
52 
53 return results;
54 }
55}

Repository z niestandardową logiką biznesową

1// advanced-tribute.repository.ts
2import { BaseRepository } from './base.repository';
3import { Tribute } from './tribute.entity';
4import { EntityRepository } from 'typeorm';
5
6@EntityRepository(Tribute)
7export class AdvancedTributeRepository extends BaseRepository<Tribute> {
8 // Logika biznesowa: znajdź tributy nadające się do sprzedaży
9 async findSellableTributes(minValue: number = 100): Promise<Tribute[]> {
10 return this.createQueryBuilder('tribute')
11 .where('tribute.value >= :minValue', { minValue })
12 .andWhere('tribute.isCursed = false') // Nie sprzedajemy przeklętych!
13 .andWhere('tribute.location != :forbiddenLocation', { 
14 forbiddenLocation: 'Tartarus'
15 })
16 .orderBy('tribute.value', 'DESC')
17 .getMany();
18 }
19
20 // Analiza rynku tributów
21 async getMarketAnalysis() {
22 const analysis = await this.createQueryBuilder('tribute')
23 .select([
24 'tribute.type',
25 'COUNT(*) as count',
26 'AVG(tribute.value) as avgValue',
27 'MIN(tribute.value) as minValue',
28 'MAX(tribute.value) as maxValue',
29 'SUM(tribute.value) as totalValue'
30 ])
31 .where('tribute.isCursed = false')
32 .groupBy('tribute.type')
33 .orderBy('totalValue', 'DESC')
34 .getRawMany();
35
36 return analysis.map(item => ({
37 type: item.tribute_type,
38 count: parseInt(item.count),
39 avgValue: parseFloat(item.avgvalue),
40 minValue: parseFloat(item.minvalue),
41 maxValue: parseFloat(item.maxvalue),
42 totalValue: parseFloat(item.totalvalue)
43 }));
44 }
45
46 // Znajdź tributy według kryteriów eksperta
47 async findByExpertCriteria(criteria: {
48 minValue?: number;
49 maxValue?: number;
50 types?: string[];
51 locations?: string[];
52 excludeCursed?: boolean;
53 discoveredAfter?: Date;
54 }): Promise<Tribute[]> {
55 let query = this.createQueryBuilder('tribute');
56
57 if (criteria.minValue) {
58 query = query.andWhere('tribute.value >= :minValue', { minValue: criteria.minValue });
59 }
60
61 if (criteria.maxValue) {
62 query = query.andWhere('tribute.value <= :maxValue', { maxValue: criteria.maxValue });
63 }
64
65 if (criteria.types && criteria.types.length > 0) {
66 query = query.andWhere('tribute.type IN (:...types)', { types: criteria.types });
67 }
68
69 if (criteria.locations && criteria.locations.length > 0) {
70 query = query.andWhere('tribute.location IN (:...locations)', { locations: criteria.locations });
71 }
72
73 if (criteria.excludeCursed) {
74 query = query.andWhere('tribute.isCursed = false');
75 }
76
77 if (criteria.discoveredAfter) {
78 query = query.andWhere('tribute.collectedAt >= :discoveredAfter', { 
79 discoveredAfter: criteria.discoveredAfter 
80 });
81 }
82
83 return query
84 .orderBy('tribute.value', 'DESC')
85 .getMany();
86 }
87
88 // Rekomendacje tributów dla konkretnego legionariusza
89 async getRecommendationsForLegionary(legionaryId: number): Promise<Tribute[]> {
90 // Złożona logika biznesowa
91 return this.createQueryBuilder('tribute')
92 .leftJoin('tribute.legionary', 'currentOwner')
93 .leftJoin('legionarys', 'legionary', 'legionary.id = :legionaryId', { legionaryId })
94 .where('currentOwner.id IS NULL') // tributy bez właściciela
95 .andWhere('tribute.isCursed = false')
96 .andWhere(
97 '(tribute.value BETWEEN legionary.totalTributeValue * 0.1 AND legionary.totalTributeValue * 2)'
98 )
99 .orderBy('tribute.value', 'DESC')
100 .limit(5)
101 .getMany();
102 }
103}

Konfiguracja Repository w Module

1// tribute.module.ts
2import { Module } from '@nestjs/common';
3import { TypeOrmModule } from '@nestjs/typeorm';
4import { TributeRepository } from './tribute.repository';
5import { AdvancedTributeRepository } from './advanced-tribute.repository';
6import { TributeService } from './tribute.service';
7import { TributeController } from './tribute.controller';
8import { Tribute } from './tribute.entity';
9
10@Module({
11 imports: [TypeOrmModule.forFeature([Tribute, TributeRepository, AdvancedTributeRepository])],
12 controllers: [TributeController],
13 providers: [TributeService],
14 exports: [TributeService, TributeRepository]
15})
16export class TributeModule {}

Używanie Repository w Service

1// tribute.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { TributeRepository } from './tribute.repository';
5import { AdvancedTributeRepository } from './advanced-tribute.repository';
6import { CreateTributeDto } from './dto/create-tribute.dto';
7
8@Injectable()
9export class TributeService {
10 constructor(
11 @InjectRepository(TributeRepository)
12 private tributeRepository: TributeRepository,
13 @InjectRepository(AdvancedTributeRepository)
14 private advancedTributeRepository: AdvancedTributeRepository,
15 ) {}
16
17 async findTributesByType(type: string) {
18 return this.tributeRepository.findByType(type);
19 }
20
21 async getMostValuable(limit: number = 10) {
22 return this.tributeRepository.findMostValuable(limit);
23 }
24
25 async getTributeMarketAnalysis() {
26 return this.advancedTributeRepository.getMarketAnalysis();
27 }
28
29 async searchTributes(criteria: any) {
30 return this.advancedTributeRepository.findByExpertCriteria(criteria);
31 }
32
33 async getRecommendations(legionaryId: number) {
34 return this.advancedTributeRepository.getRecommendationsForLegionary(legionaryId);
35 }
36
37 async createTribute(createTributeDto: CreateTributeDto) {
38 const tribute = this.tributeRepository.create(createTributeDto);
39 return this.tributeRepository.save(tribute);
40 }
41
42 async getTotalTributeValue(): Promise<number> {
43 return this.tributeRepository.getTotalValue();
44 }
45}

Testowanie Repository

1// tribute.repository.spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { getRepositoryToken } from '@nestjs/typeorm';
4import { TributeRepository } from './tribute.repository';
5import { Tribute } from './tribute.entity';
6
7describe('TributeRepository', () => {
8 let repository: TributeRepository;
9
10 const mockTributeRepository = {
11 find: jest.fn(),
12 findOne: jest.fn(),
13 save: jest.fn(),
14 create: jest.fn(),
15 createQueryBuilder: jest.fn(() => ({
16 where: jest.fn().mockReturnThis(),
17 orderBy: jest.fn().mockReturnThis(),
18 getMany: jest.fn(),
19 getRawOne: jest.fn()
20 }))
21 };
22
23 beforeEach(async () => {
24 const module: TestingModule = await Test.createTestingModule({
25 providers: [
26 {
27 provide: getRepositoryToken(TributeRepository),
28 useValue: mockTributeRepository,
29 },
30 ],
31 }).compile();
32
33 repository = module.get<TributeRepository>(getRepositoryToken(TributeRepository));
34 });
35
36 describe('findByType', () => {
37 it('should find tributes by type', async () => {
38 const mockTributes = [
39 { id: 1, name: 'Golden Coin', type: 'gold', value: 100 },
40 { id: 2, name: 'Gold Bar', type: 'gold', value: 500 }
41 ];
42
43 mockTributeRepository.find.mockResolvedValue(mockTributes);
44
45 const result = await repository.findByType('gold');
46
47 expect(mockTributeRepository.find).toHaveBeenCalledWith({
48 where: { type: 'gold' },
49 order: { collectedAt: 'DESC' }
50 });
51 expect(result).toEqual(mockTributes);
52 });
53 });
54
55 describe('getTotalValue', () => {
56 it('should calculate total tribute value', async () => {
57 const mockQueryBuilder = {
58 select: jest.fn().mockReturnThis(),
59 getRawOne: jest.fn().mockResolvedValue({ total: '1500.50' })
60 };
61
62 mockTributeRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
63
64 const result = await repository.getTotalValue();
65
66 expect(result).toBe(1500.50);
67 });
68 });
69});

Najlepsze praktyki Repository Pattern

1. Czytelne nazwy metod

1// DOBRZE
2findActiveBySkillLevel(skillName: string, minLevel: number)
3getTributeStatisticsByType(type: string)
4findLegionarysWithoutCenturion()
5
6// ŹLE
7findLegionarys(criteria: any)
8getData(params: any)
9query(sql: string)

2. Zwracanie odpowiednich typów

1// DOBRZE
2async findById(id: number): Promise<Tribute | null>
3async getTotalCount(): Promise<number>
4async findWithPagination(): Promise<PaginatedResult<Tribute>>
5
6// ŹLE
7async findById(id: number): Promise<any>
8async getStats(): Promise<any>

3. Obsługa błędów

1async findTributeById(id: number): Promise<Tribute> {
2 if (id <= 0) {
3 throw new BadRequestException('ID must be positive');
4 }
5
6 const tribute = await this.findOne({ where: { id } });
7 
8 if (!tribute) {
9 throw new NotFoundException(`Tribute with ID ${id} not found`);
10 }
11
12 return tribute;
13}

Podsumowanie

Gratulacje! Teraz jesteś mistrzem organizacji tributów! Umiesz:

Tworzyć custom repository z logiką biznesową ✓ Używać zaawansowanych zapytań w repository ✓ Implementować abstrakcyjne bazy dla wspólnej funkcjonalności ✓ Testować repository z mockami ✓ Organizować kod według najlepszych praktyk

Repository Pattern to klucz do utrzymania porządku w dostępie do danych - jak najlepszy zarządca w aplikacji!

Przejdź do CodeWorlds