Skip to content

Commit

Permalink
Merge pull request #2 from nxcd/feature/event-repository-class
Browse files Browse the repository at this point in the history
Refactor repositories to add new EventRepository abstract class
  • Loading branch information
roziscoding authored Nov 28, 2018
2 parents 5bc93d5 + c430fa1 commit aa98f72
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 79 deletions.
123 changes: 62 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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> { // 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<TEntity>`) 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<Entity> {
new(events?: IEvent<any>[]): 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<Entity> {
new(events?: IEvent<any>[]): 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<TEntity extends IEventEntity> {

```ts
import { Db, MongoClient } from 'mongodb'
import { MongodbEventRepository } from '@nxcd/paradox'
import { Person } from './classes/Person'
protected readonly _Entity: IEntityConstructor<TEntity>

class PersonRepository extends MongodbEventRepository<Person> {
constructor(connection: Db) {
super(connection.collection(Person.collection), Person)
constructor (Entity: IEntityConstructor<TEntity>) {
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<TEntity>

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<TEntity | null>

return { entities, count, range, total }
}
abstract async runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 }): Promise<IPaginatedQueryResult<{ events: IEvent<TEntity>[] }>>
}
```

(async function () {
const connection = (await MongoClient.connect('mongodb://urldomongodbaqui')).db('crowd')
const personRepository = new PersonRepository(connection)
const johnDoe = Person.create('[email protected]', '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: '[email protected]' }, 'jdoe')
const [ janeDoe ] = allJanes
janeDoe.changeEmail({ newEmail: '[email protected]' }, '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 <Entity>
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> { // 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
74 changes: 74 additions & 0 deletions docs/MongodbEventRepository.md
Original file line number Diff line number Diff line change
@@ -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<TEntity>`) 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<Person> {
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('[email protected]', '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: '[email protected]' }, 'jdoe')
const [ janeDoe ] = allJanes
janeDoe.changeEmail({ newEmail: '[email protected]' }, '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
22 changes: 22 additions & 0 deletions src/classes/repositories/EventRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { IEvent } from '@nxcd/tardis'
import { IEventEntity } from '../../interfaces/IEventEntity'
import { IPaginatedQueryResult } from '../../interfaces/IPaginatedQueryResult'

export interface IEntityConstructor<Entity> {
new(events?: IEvent<any>[]): Entity
}

export abstract class EventRepository<TEntity extends IEventEntity> {

protected readonly _Entity: IEntityConstructor<TEntity>

constructor (Entity: IEntityConstructor<TEntity>) {
this._Entity = Entity
}

abstract async save (entity: TEntity): Promise<TEntity>

abstract async findById (id: any): Promise<TEntity | null>

abstract async _runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 }): Promise<IPaginatedQueryResult<{ events: IEvent<TEntity>[] }>>
}
28 changes: 17 additions & 11 deletions src/classes/repositories/MongodbEventRepository.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
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 {
state: any
events: IEvent<any>[]
}

interface Constructor<Entity> {
new(events?: IEvent<any>[]): Entity
}

export abstract class MongodbEventRepository<TEntity extends IEventEntity> implements IEventRepository<TEntity> {
export abstract class MongodbEventRepository<TEntity extends IEventEntity> extends EventRepository<TEntity> {
protected _collection: Collection
private _Entity: Constructor<TEntity>

constructor (collection: Collection, Entity: Constructor<TEntity>) {
constructor (collection: Collection, Entity: IEntityConstructor<TEntity>) {
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 {
Expand Down Expand Up @@ -76,7 +77,7 @@ export abstract class MongodbEventRepository<TEntity extends IEventEntity> 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<IPaginatedQueryResult<{ events: IEvent<TEntity>[] }>> {
async _runPaginatedQuery (query: { [key: string]: any }, page: number, size: number, sort: { [field: string]: 1 | -1 } = {}): Promise<IPaginatedQueryResult<{ events: IEvent<TEntity>[] }>> {
const skip = (Number(page) - 1) * Number(size)
const limit = Number(size)

Expand Down Expand Up @@ -136,6 +137,11 @@ export abstract class MongodbEventRepository<TEntity extends IEventEntity> 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)
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
4 changes: 0 additions & 4 deletions src/interfaces/IEventRepository.ts

This file was deleted.

4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -73,4 +73,4 @@
"exclude": [
"./examples"
]
}
}

0 comments on commit aa98f72

Please sign in to comment.