Skip to content

Commit adc503f

Browse files
committed
feat(mongo-basic-repo): add MongoBasicRepo implementation with integration tests
1 parent c672a53 commit adc503f

2 files changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { ClientSession, Collection, Document, MongoClient, ObjectId } from 'mongodb';
2+
3+
interface ILogger {
4+
log: (message: string, ...optionalParams: any[]) => void;
5+
debug: (message: string, ...optionalParams: any[]) => void;
6+
warn: (message: string, ...optionalParams: any[]) => void;
7+
error: (message: string, ...optionalParams: any[]) => void;
8+
}
9+
10+
export type WithOptionalVersion<T> = T & { __version?: number };
11+
export type WithVersion<T> = T & { __version: number };
12+
export type DocumentWithId = { id: string } & Document;
13+
14+
export type MongoDocument<T> = T & {
15+
_id: ObjectId;
16+
createdAt: Date;
17+
updatedAt: Date;
18+
};
19+
20+
export abstract class MongoBasicRepo<M extends DocumentWithId> {
21+
protected readonly collection: Collection<M>;
22+
protected readonly MONGODB_UNIQUE_INDEX_CONSTRAINT_ERROR = 11000;
23+
24+
constructor(
25+
protected readonly mongoClient: MongoClient,
26+
protected readonly collectionName: string,
27+
collection?: Collection<M>,
28+
protected readonly logger: ILogger = console,
29+
) {
30+
if (!collection) {
31+
this.collection = mongoClient.db().collection(this.collectionName);
32+
}
33+
}
34+
35+
async init() {
36+
await this.collection.createIndex({ id: 1 }, { unique: true });
37+
}
38+
39+
async save(document: WithOptionalVersion<M>) {
40+
const documentVersion = document.__version || 0;
41+
const session = this.mongoClient.startSession();
42+
try {
43+
await session.withTransaction(async () => {
44+
await this.upsertWriteModel(document, documentVersion, session);
45+
});
46+
} catch (e) {
47+
this.catchSaveTransaction(e, documentVersion, document);
48+
} finally {
49+
await session.endSession();
50+
}
51+
}
52+
53+
protected async upsertWriteModel(model: M, modelVersion: number, session: ClientSession) {
54+
await this.collection.updateOne(
55+
{ id: model.id, __version: modelVersion } as any,
56+
{
57+
$set: {
58+
...(model as M),
59+
__version: modelVersion + 1,
60+
createdAt: undefined, // TODO make sure createdAt is not set again
61+
updatedAt: new Date(),
62+
},
63+
$setOnInsert: { createdAt: new Date() } as any,
64+
},
65+
{ upsert: true, session, ignoreUndefined: true },
66+
);
67+
this.logger.debug(
68+
`Document with id ${model.id} and version ${modelVersion} saved successfully. ${JSON.stringify(model)}`,
69+
);
70+
}
71+
72+
protected catchSaveTransaction(e: any, documentVersion: number, document: M) {
73+
// FIXME verify the field name because constraints could be added on other fields
74+
if (e.code === this.MONGODB_UNIQUE_INDEX_CONSTRAINT_ERROR) {
75+
if (isTheFirstVersion(documentVersion)) {
76+
throw new Error(`Cannot save document with id: ${document.id} due to duplicated id.`);
77+
} else {
78+
throw new Error(`Cannot save document with id: ${document.id} due to optimistic locking.`);
79+
}
80+
}
81+
throw e;
82+
83+
function isTheFirstVersion(documentVersion: number) {
84+
return documentVersion === 0;
85+
}
86+
}
87+
88+
public async getById(id: string): Promise<MongoDocument<WithVersion<M>> | null> {
89+
const document = await this.collection.findOne<MongoDocument<WithVersion<M>>>({ id: id } as any);
90+
this.logger.debug(`Retrieving document with id ${id}. Found: ${JSON.stringify(document)}`);
91+
if (!document) return null;
92+
93+
return document;
94+
}
95+
96+
public async getByIdOrThrow(id: string): Promise<WithVersion<M>> {
97+
const document = await this.getById(id);
98+
if (!document) throw new Error(`Document ${id} not found.`);
99+
return document;
100+
}
101+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { MongoBasicRepo } from '../mongo-basic.repo';
2+
import { MongoClient } from 'mongodb';
3+
import { MongoMemoryReplSet } from 'mongodb-memory-server';
4+
5+
interface TestModel {
6+
id: string;
7+
data: string;
8+
}
9+
10+
class TestRepository extends MongoBasicRepo<TestModel> {}
11+
12+
describe('MongoBasicRepo MongoDB Integration', () => {
13+
let mongodb: MongoMemoryReplSet;
14+
let mongoClient: MongoClient;
15+
let basicRepo: MongoBasicRepo<TestModel>;
16+
const collectionName = 'collectionName';
17+
18+
beforeAll(async () => {
19+
mongodb = await MongoMemoryReplSet.create({
20+
replSet: {
21+
count: 1,
22+
dbName: 'test',
23+
storageEngine: 'wiredTiger',
24+
},
25+
});
26+
mongoClient = new MongoClient(mongodb.getUri());
27+
await mongoClient.connect();
28+
29+
basicRepo = new TestRepository(mongoClient, collectionName, undefined, undefined);
30+
await basicRepo.init();
31+
});
32+
33+
afterEach(async () => {
34+
jest.resetAllMocks();
35+
await mongoClient.db().collection(collectionName).deleteMany({});
36+
});
37+
38+
afterAll(async () => {
39+
await mongoClient.db().collection(collectionName).drop();
40+
await mongoClient.close();
41+
await mongodb.stop();
42+
});
43+
44+
describe('Save and Get', () => {
45+
describe('Given an existing model', () => {
46+
describe('When saving', () => {
47+
const id1 = 'id1';
48+
beforeEach(async () => {
49+
await basicRepo.save({ id: id1, data: 'value' });
50+
});
51+
52+
it('should be saved', async () => {
53+
expect(await basicRepo.getById(id1)).toMatchObject({
54+
id: id1,
55+
data: 'value',
56+
});
57+
});
58+
});
59+
});
60+
61+
describe('Given a non existing document', () => {
62+
describe('When getById', () => {
63+
it('should return null', async () => {
64+
expect(await basicRepo.getById('not-existing-id')).toBeNull();
65+
});
66+
});
67+
68+
describe('When getByIdOrThrow', () => {
69+
it('should throw error', async () => {
70+
await expect(() => basicRepo.getByIdOrThrow('not-existing-id')).rejects.toThrow();
71+
});
72+
});
73+
});
74+
});
75+
76+
describe('Optimistic Lock', () => {
77+
describe('Given a saved document', () => {
78+
const id1 = 'id1';
79+
beforeEach(async () => {
80+
await basicRepo.save({ id: id1, data: 'value' });
81+
});
82+
83+
describe('When getting from db the aggregate', () => {
84+
it('should return a document with version 1', async () => {
85+
expect(await basicRepo.getById(id1)).toMatchObject({ __version: 1 });
86+
});
87+
});
88+
89+
describe('When saving a new instance with the same id', () => {
90+
it('should throw due to unique index on id', async () => {
91+
const newDocument = { id: id1, data: 'newValue' };
92+
await expect(async () => await basicRepo.save(newDocument)).rejects.toThrow('duplicated id');
93+
});
94+
});
95+
96+
describe('When saving a new instance with undefined __version', () => {
97+
it('should throw due to optimistic locking', async () => {
98+
const newDocument = { id: id1, data: 'newValue', __version: undefined };
99+
await expect(async () => await basicRepo.save(newDocument)).rejects.toThrow('duplicated id');
100+
});
101+
});
102+
103+
describe('When saving and getting multiple times the document', () => {
104+
it('should increase the version', async () => {
105+
const firstInstance = await basicRepo.getById(id1);
106+
107+
if (firstInstance === null) throw new Error('Not found');
108+
await basicRepo.save(firstInstance);
109+
110+
const secondInstance = await basicRepo.getById(id1);
111+
if (secondInstance === null) throw new Error('Not found');
112+
await basicRepo.save(secondInstance);
113+
114+
const thirdInstance = await basicRepo.getById(id1);
115+
if (thirdInstance === null) throw new Error('Not found');
116+
expect(thirdInstance).toMatchObject({ __version: 3 });
117+
});
118+
});
119+
120+
describe('When saving an outdated document', () => {
121+
it('should throw an optimistic locking error', async () => {
122+
const firstInstance = await basicRepo.getById(id1);
123+
if (firstInstance === null) throw new Error('Not found');
124+
125+
const secondInstance = await basicRepo.getById(id1);
126+
if (secondInstance === null) throw new Error('Not found');
127+
await basicRepo.save(secondInstance);
128+
129+
await expect(async () => await basicRepo.save(firstInstance)).rejects.toThrow('optimistic locking');
130+
});
131+
});
132+
});
133+
});
134+
});

0 commit comments

Comments
 (0)