diff --git a/README.md b/README.md index 37edcbb..7e011ec 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,11 @@ - [API Summary](#api-summary) - [EventEntity](#evententity) - [Repositories](#repositories) - - [MongodbEventRepository](#mongodbeventrepository) - - [Sessions](#sessions) - [Interfaces](#interfaces) - [IPaginatedQueryResult](#ipaginatedqueryresult) + - [IEntityConstructor](#ientityconstructor) +- [My repository is not included, what do I do?](#my-repository-is-not-included-what-do-i-do) + - [Adding your repository to the list](#adding-your-repository-to-the-list) ## Instalation @@ -279,90 +280,90 @@ Since different databases have different event sourcing implementations, for now > Note that different repository classes might behave differently depending on who created the class, please refer to the PR section or fill in an issue if you're experiencing troubles. -### MongodbEventRepository +- [MongoDB event-based repository](./docs/MongodbEventRepository.md) -Data repository made for MongoDB databases. This repository **must** be extended by another class implementing its own methods. The base abstract class must have some properties when instantiated such as: +## Interfaces -- Must receive the `Collection` object from Mongodb +### IPaginatedQueryResult -> **Note:** The `collection` **OBJECT**, not the collection **NAME** +Represents a paginated query: -- Must receive the main entity constructor (not the instance) +```ts +interface IPaginatedQueryResult { // TDocument is the type that represents the data which will be returned from the database (it is used internally) + documents: TDocument[] // Documents in the current page + count: number // Total results in the page + range: { + from: number, // Index of the first result + to: number // Index of the last result + } + total: number // Query total +} +``` -By default, the class already has some base methods: +### IEntityConstructor -- `save (entity: TEntity)`: Which will serialize and save the received entity (which must be of the same type you passed to the generic type `TEntity` in `MongodbEventRepository`) on the database. +Represents the constructor of an entity -> This method works by firstly trying to find the entity by its ID, if the ID cannot be found in the database, then a new document will be created, following the `{_id, events, state}` format where `events` should start as an empty array and, at each `save`, the `pendingEvents` array will be merged to it. Soon after that, the `confirmEvents` method will be called, thus clearing the `pendingEvents` array. -> -> `state` will be the last reduced state of the entity, which will be obtained by calling the `state` getter we just defined earlier. +```ts +interface IEntityConstructor { + new(events?: IEvent[]): Entity +} +``` -- `findById (id: ObjectId)`: Will search in the database for a record with the informed `id`. +## My repository is not included, what do I do? -> This record should be created when the class is instantiated using the `create` method +Since this lib is open source and generic enough to be used by multiple repositories, there's no way to know which repositories the users are going to be using. So we added a way for you to create your own. -- `bulkUpdate (entities: IEventEntity[])`: Save events from several instances of an entity at once -- `withSession (session: ClientSession)`: Begins a MongoDB session to initiate a transaction (only on Mongo 4.0) and returns an object with the available methods which can be executed within a session. If this following command throws an error, the whole session suffers a rollback, otherwise it is commited. -- `_runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [key: string]: 1|-1 } = {})`: Executes a query aplying pagination to the result. Returns an object that follows the [IPaginatedQueryResult](#ipaginatedqueryresult) interface. +In order to create a repository, your class **must** extend the `EventRepository` class, which is fully abstract and is as follows: -#### Sessions +```ts +export interface IEntityConstructor { + new(events?: IEvent[]): Entity +} -If your MongoDB version is 4.0 or higher (with transaction support), in order to execute a command using a transaction, follow this example: +export abstract class EventRepository { -```ts -import { Db, MongoClient } from 'mongodb' -import { MongodbEventRepository } from '@nxcd/paradox' -import { Person } from './classes/Person' + protected readonly _Entity: IEntityConstructor -class PersonRepository extends MongodbEventRepository { - constructor(connection: Db) { - super(connection.collection(Person.collection), Person) + constructor (Entity: IEntityConstructor) { + this._Entity = Entity } - async search (filters: { name: string }, page: number = 1, size: number = 50) { - const query = filters.name - ? { 'state.name': filters.name } - : { } + abstract async save (entity: TEntity): Promise - const { documents, count, range, total } = await this._runPaginatedQuery(query, page, size) - const entities = documents.map(({ events }) => new Person().setPersistedEvents(events)) + abstract async findById (id: any): Promise - return { entities, count, range, total } - } + abstract async runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 }): Promise[] }>> } +``` -(async function () { - const connection = (await MongoClient.connect('mongodb://urldomongodbaqui')).db('crowd') - const personRepository = new PersonRepository(connection) - const johnDoe = Person.create('johndoe@doe.com', 'jdoe') - await personRepository.save(johnDoe) // Creates a new event - const allJanes = await personRepository.search({ name: 'jane' }, 1, 10) // Returns an object following IPaginatedQueryResult interface +In order to maintain consistency between implementations, the following methods **must** be implemented: - johnDoe.changeEmail({ newEmail: 'johndoe@company.com' }, 'jdoe') - const [ janeDoe ] = allJanes - janeDoe.changeEmail({ newEmail: 'janedoe@doe.com' }, 'janedoe') +- `save`: Should save the given entity to the database and return the entity +- `findById`: Should find an entity by its ID in the database. It is important to notice that, once found, the returned value should be a newly created instance of that entity (this is where you're going to use the `setPersistedEvents` method) +- `runPaginatedQuery`: Should return a paginated query from the database + +Besides these methods, any class that extends `EventRepository` will inherit the `_Entity` property, which refers to the entity constructor. This will be used when returning the newly created entity from the database during the `findById` method and seting its persisted events on the newly instantiated class, like so: - const session = connection.startSession() - await personRepository.withSession(session).bulkUpdate([ johnDoe, janeDoe ]) // Updates both entities using a transaction -})() +```ts +async function findById (id) { + /* finds the data */ + const instance = this._Entity() // Creates a new instance of + return instance.setPersistedEvents(yourEvents) // Sets the returned data into the instance +} ``` -> If you version does **not** support transactions, an Database Error is thrown +Those are the required implementations, any additional functionalities you'd like to include in the repository can be added at will. -## Interfaces +> For further explanation and examples, refer to the [MongodbEventRepository file in the `src` folder](./docs/MongodbEventRepository.md) -### IPaginatedQueryResult +### Adding your repository to the list -Represents a paginated query: +If you'd like to add your repository to the list of included repositories, please fill in a PR and don't forget to stick to some conventions: -```ts -interface IPaginatedQueryResult { // TDocument is the type that represents the data which will be returned from the database (it is used internally) - documents: TDocument[] // Documents in the current page - count: number // Total results in the page - range: { - from: number, // Index of the first result - to: number // Index of the last result - } - total: number // Query total -} -``` +- All names are CamelCase +- Private variables come with an `_` preceding it +- Do **not** forget to add the documentation to this repository in the `docs/` folder (the file should be the same name as your class) +- Do **not** forget to add your repository to the list in this README along with the link to its own docs + +Thank you for your contribution :D \ No newline at end of file diff --git a/docs/MongodbEventRepository.md b/docs/MongodbEventRepository.md new file mode 100644 index 0000000..dc5cbd1 --- /dev/null +++ b/docs/MongodbEventRepository.md @@ -0,0 +1,74 @@ +# MongodbEventRepository + +- [MongodbEventRepository](#mongodbeventrepository) + - [Sessions](#sessions) + +Data repository made for MongoDB databases. This repository **must** be extended by another class implementing its own methods. The base abstract class must have some properties when instantiated such as: + +- Must receive the `Collection` object from Mongodb + +> **Note:** The `collection` **OBJECT**, not the collection **NAME** + +- Must receive the main entity constructor (not the instance) + +By default, the class already has some base methods: + +- `save (entity: TEntity)`: Which will serialize and save the received entity (which must be of the same type you passed to the generic type `TEntity` in `MongodbEventRepository`) on the database. + +> This method works by firstly trying to find the entity by its ID, if the ID cannot be found in the database, then a new document will be created, following the `{_id, events, state}` format where `events` should start as an empty array and, at each `save`, the `pendingEvents` array will be merged to it. Soon after that, the `confirmEvents` method will be called, thus clearing the `pendingEvents` array. +> +> `state` will be the last reduced state of the entity, which will be obtained by calling the `state` getter we just defined earlier. + +- `findById (id: ObjectId)`: Will search in the database for a record with the informed `id`. + +> This record should be created when the class is instantiated using the `create` method + +- `bulkUpdate (entities: IEventEntity[])`: Save events from several instances of an entity at once +- `withSession (session: ClientSession)`: Begins a MongoDB session to initiate a transaction (only on Mongo 4.0) and returns an object with the available methods which can be executed within a session. If this following command throws an error, the whole session suffers a rollback, otherwise it is commited. +- `_runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [key: string]: 1|-1 } = {})`: Executes a query aplying pagination to the result. Returns an object that follows the [IPaginatedQueryResult](#ipaginatedqueryresult) interface. + +> **Note:** `_runPaginatedQuery` should only be used **inside** the child class because it'll return a collection of **documents** and not a collection of **entities**. This harms the principle for entity-first design this repository is all about. In order to create a useful method, please refer to the example below on how to create a `search` method using `_runPaginatedQuery` properly + +## Sessions + +If your MongoDB version is 4.0 or higher (with transaction support), in order to execute a command using a transaction, follow this example: + +```ts +import { Db, MongoClient } from 'mongodb' +import { MongodbEventRepository } from '@nxcd/paradox' +import { Person } from './classes/Person' + +class PersonRepository extends MongodbEventRepository { + constructor(connection: Db) { + super(connection.collection(Person.collection), Person) + } + + async search (filters: { name: string }, page: number = 1, size: number = 50) { + const query = filters.name + ? { 'state.name': filters.name } + : { } + + const { documents, count, range, total } = await this._runPaginatedQuery(query, page, size) + const entities = documents.map(({ events }) => new Person().setPersistedEvents(events)) + + return { entities, count, range, total } + } +} + +(async function () { + const connection = (await MongoClient.connect('mongodb://urldomongodbaqui')).db('crowd') + const personRepository = new PersonRepository(connection) + const johnDoe = Person.create('johndoe@doe.com', 'jdoe') + await personRepository.save(johnDoe) // Creates a new event + const allJanes = await personRepository.search({ name: 'jane' }, 1, 10) // Returns an object following IPaginatedQueryResult interface + + johnDoe.changeEmail({ newEmail: 'johndoe@company.com' }, 'jdoe') + const [ janeDoe ] = allJanes + janeDoe.changeEmail({ newEmail: 'janedoe@doe.com' }, 'janedoe') + + const session = connection.startSession() + await personRepository.withSession(session).bulkUpdate([ johnDoe, janeDoe ]) // Updates both entities using a transaction +})() +``` + +> If you version does **not** support transactions, an Database Error is thrown \ No newline at end of file diff --git a/src/classes/repositories/EventRepository.ts b/src/classes/repositories/EventRepository.ts new file mode 100644 index 0000000..06a90e0 --- /dev/null +++ b/src/classes/repositories/EventRepository.ts @@ -0,0 +1,22 @@ +import { IEvent } from '@nxcd/tardis' +import { IEventEntity } from '../../interfaces/IEventEntity' +import { IPaginatedQueryResult } from '../../interfaces/IPaginatedQueryResult' + +export interface IEntityConstructor { + new(events?: IEvent[]): Entity +} + +export abstract class EventRepository { + + protected readonly _Entity: IEntityConstructor + + constructor (Entity: IEntityConstructor) { + this._Entity = Entity + } + + abstract async save (entity: TEntity): Promise + + abstract async findById (id: any): Promise + + abstract async _runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 }): Promise[] }>> +} \ No newline at end of file diff --git a/src/classes/repositories/MongodbEventRepository.ts b/src/classes/repositories/MongodbEventRepository.ts index 927de8e..104e396 100644 --- a/src/classes/repositories/MongodbEventRepository.ts +++ b/src/classes/repositories/MongodbEventRepository.ts @@ -1,7 +1,7 @@ import { IEvent } from '@nxcd/tardis' -import { Collection, ObjectId, ClientSession } from 'mongodb' +import { EventRepository, IEntityConstructor } from './EventRepository' import { IEventEntity } from '../../interfaces/IEventEntity' -import { IEventRepository } from '../../interfaces/IEventRepository' +import { Collection, ObjectId, ClientSession } from 'mongodb' import { IPaginatedQueryResult } from '../../interfaces/IPaginatedQueryResult' interface IDatabaseDocument { @@ -9,19 +9,20 @@ interface IDatabaseDocument { events: IEvent[] } -interface Constructor { - new(events?: IEvent[]): Entity -} - -export abstract class MongodbEventRepository implements IEventRepository { +export abstract class MongodbEventRepository extends EventRepository { protected _collection: Collection - private _Entity: Constructor - constructor (collection: Collection, Entity: Constructor) { + constructor (collection: Collection, Entity: IEntityConstructor) { + super(Entity) this._collection = collection - this._Entity = Entity } + /** + * Tries to execute function using given session + * @param {Function} fn Function to be executed + * @param {ClientSession} session MongoDB user session + * @returns {*} Function result + */ protected async _tryRunningWithSession (fn: Function, session: ClientSession) { session.startTransaction() try { @@ -76,7 +77,7 @@ export abstract class MongodbEventRepository imple * @param {{[field: string]: 1|-1}} sort Fields to sort * @returns {Promise} Set of results */ - protected async _runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 } = {}): Promise[] }>> { + async _runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 } = {}): Promise[] }>> { const skip = (Number(page) - 1) * Number(size) const limit = Number(size) @@ -136,6 +137,11 @@ export abstract class MongodbEventRepository imple return new this._Entity().setPersistedEvents(document.events) } + /** + * Executes the following command using a MongoDB session + * @param {ClientSession} session MongoDB client session + * @returns {{bulkUpdate: Function}} Available commands + */ withSession (session: ClientSession) { return { bulkUpdate: (entities: IEventEntity[]) => this._tryRunningWithSession(() => this.bulkUpdate(entities, session), session) diff --git a/src/index.ts b/src/index.ts index 9a08c0f..7788cb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export { EventEntity } from './classes/EventEntity' export { IEventEntity } from './interfaces/IEventEntity' -export { IEventRepository } from './interfaces/IEventRepository' +export * from './classes/repositories/EventRepository' export { IPaginatedQueryResult } from './interfaces/IPaginatedQueryResult' export { MongodbEventRepository } from './classes/repositories/MongodbEventRepository' diff --git a/src/interfaces/IEventRepository.ts b/src/interfaces/IEventRepository.ts deleted file mode 100644 index 237b3d5..0000000 --- a/src/interfaces/IEventRepository.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IEventRepository { - save (entity: Entity): Promise - findById (id: any): Promise -} diff --git a/tsconfig.json b/tsconfig.json index c1e4885..874fdca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,7 @@ /* Additional Checks */ "noUnusedLocals": true, /* Report errors on unused locals. */ - "noUnusedParameters": true, + "noUnusedParameters": false, /* Report errors on unused parameters. */ "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ @@ -73,4 +73,4 @@ "exclude": [ "./examples" ] -} +} \ No newline at end of file