ArangoDB module for the NestJS framework built on top of ArangoJS.
In your existing NestJS-based project:
$ npm i --save nest-arango arangojs
With the package installed, we can import ArangoModule
into the root AppModule
.
import { ArangoModule } from 'nest-arango';
@Module({
imports: [
ArangoModule.forRoot({
config: {
url: 'http://localhost:8529',
...
},
}),
],
...
})
export class AppModule { }
Alternatively, if we need to inject environment variables into ArangoModule
, we can use configuration namespaces combined with the forRootAsync()
method as shown below.
// arango.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('arango', () => ({
url: process.env.DATABASE__URL,
password: process.env.DATABASE__PASSWORD,
}));
// app.module.ts
import { ArangoModule } from 'nest-arango';
import arangoConfig from 'arango.config';
@Module({
imports: [
ConfigModule.forRoot(
{
load: [arangoConfig]
}),
ArangoModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
config: {
url: configService.getOrThrow<string>('arango.url'),
auth: {
username: 'root',
password: configService.getOrThrow<string>('arango.password'),
},
},
}),
inject: [ConfigService],
}),
],
...
})
export class AppModule { }
In ArangoDB, every record is a document stored in a collection. A collection can be defined as a collection of vertices or edges. We can define a document entity as shown below:
import { Collection, ArangoDocument } from 'nest-arango';
@Collection('Users')
export class UserEntity extends ArangoDocument {
username: string;
email?: string;
created_at?: Date;
updated_at?: Date;
}
The @Collection()
decorator defines the name of the collection containing documents like this one. In ArangoDB, collection names are case-sensitive.
Notice that UserEntity
inherits from ArangoDocument
. This type contains the standard metadata that ArangoDB uses (_id
, _key
, _rev
). For edges, we can use the ArangoDocumentEdge
type, which additionally includes edge metadata (_from
, to
). For more information about ArangoDB documents, refer to the official ArangoDB Documentation.
ArangoRepository
is a generic wrapper for ArangoJS methods that aims to simplify the usage of its CRUD methods, while adding some extra methods to fit our basic needs. To be able to inject the repository into our application, we need to extend AppModule
from the earlier example with ArangoModule.forFeature([...])
and register all the entities bound to document collections (see the example below).
import { ArangoModule } from 'nest-arango';
@Module({
imports: [
ArangoModule.forRoot({
config: {
url: 'http://localhost:8529',
...
},
}),
ArangoModule.forFeature([UserEntity])
],
...
})
export class AuthModule { }
Now we can inject the repository from inside of our service class using the @InjectRepository()
decorator. As mentioned earlier, ArangoRepository
is generic, so we need to pass the type of the entity to it. This type has to be registered in the ArangoModule.forFeature([...])
call mentioned above. See the example below:
import { Injectable } from '@nestjs/common';
import { InjectRepository, ArangoRepository } from 'nest-arango';
import { UserEntity } from './entities/user.entity';
@Injectable()
export class AppService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: ArangoRepository<UserEntity>,
) {}
}
Now we can use all the repository methods.
When working with methods that return the generic
ArangoNewOldResult
type, you can access its elements the same way as with an array, or using itsnew
andold
getters (depending on the options you specify when callingArangoRepository
methods,old
may or may not be defined).
ArangoManager
is a simple utility class that holds the ArangoJS Database
object reference, and contains a method for beginning transactions in a slightly more concise way. It is registered automatically within ArangoModule
. To inject ArangoManager
, we can use the @InjectManager()
decorator:
import { Injectable } from '@nestjs/common';
import { ArangoManager, InjectManager } from 'nest-arango';
@Injectable()
export class AppService {
constructor(
@InjectManager()
private databaseManager: ArangoManager;
) {}
}
With ArangoManager
injected, you can directly access the ArangoJS Database
object and begin transactions.
There are two ways to work with transactions. The first is one is to begin the transaction through ArangoManager
and define every step of the transaction by ourselves as described in the ArangoJS docs here. Alternatively, we can pass the transaction reference directly to an ArangoRepository
method to improve readability. These methods internally execute transaction steps. Below is an example of the latter approach.
...
@Injectable()
export class AppService {
constructor(
@InjectManager()
private databaseManager: ArangoManager,
@InjectRepository(UserEntity)
private readonly userRepository: ArangoRepository<UserEntity>,
@InjectRepository(KnowsEntity)
private readonly knowsRepository: ArangoRepository<KnowsEntity>,
) {}
async executeInTransaction(user1: UserEntity, user2: UserEntity) {
const trx = await this.databaseManager.beginTransaction({
write: ['Knows']
});
try {
// edge collection => [User -> Knows -> User]
await this.knowsRepository.save(
{
_from: user1._id,
_to: user2._id_
},
{
transaction: trx
}
);
await trx.commit();
} catch (error) {
await trx.abort();
}
}
}
Event listeners are used to modify the entity or execute code before and/or after the internal calls of ArangoRepository
methods. Here is an example:
import { BeforeSave } from 'nest-arango';
@Collection('Users')
export class UserEntity extends ArangoDocument {
username: string;
email?: string;
created_at?: Date;
updated_at?: Date;
@BeforeSave()
async beforeSave(context: EventListenerContext) {
await context.repository.save(
{
_key: `beforeSave${context.data.order}`,
name: `beforeSave${context.data.order}`,
},
{ emitEvents: false },
);
}
}
The @BeforeSave()
decorator marks an entity method for execution when an entity is saved through ArangoRepository
. These decorators expect methods to have an optional parameter of type EventListenerContext
, which is used to pass data to decorated methods from the repository (method parameters can also be left blank).
Currently available listener decorators:
@BeforeSave()
- executes method beforesave
andsaveAll
@AfterSave()
- executes method aftersave
,saveAll
andupsert
(if the 'insert' part of upsert is used)@BeforeUpdate()
- executes method beforeupdate
andupdateAll
@AfterUpdate()
- executes method afterupdate
,updateAll
andupsert
(if the 'update' part of upsert is used)@BeforeReplace()
- executes method beforereplace
andreplaceAll
@AfterReplace()
- executes method afterreplace
andreplaceAll
@BeforeUpsert()
- executes method beforeupsert
@AfterRemove()
- executes method afterremove
,removeBy
andremoveAll
The nest-arango
package also provides an experimental CLI tool to manage database migrations. We can directly use the cli.js
provided within the package, but first we need to define a configuration file with the name nest-arango.json
in your root folder. Here is an example:
{
"database": {
"url": "http://localhost:8529",
"databaseName": "env:ARANGO__DATABASE",
"auth": {
"username": "env:ARANGO__USERNAME",
"password": "env:ARANGO__PASSWORD"
},
"agentOptions": {
"rejectUnauthorized": "env:ARANGO__REJECT_UNAUTHORIZED_CERT:boolean"
}
},
"migrationsCollection": "Migrations",
"cli": {
"migrationsDir": "migrations"
}
}
- The
database
field has the same structure as the database configuration inArangoModule
. We can pass values as plain text, or we can provide a reference to an environment variable from our.env
file. Optionally, we can also specify a type for the environment variable (seerejectUnauthorized
in the example above). Currently, we can specify these types:boolean
|number
|string
. By default, all variables are parsed as strings. - The
migrationDir
field defines the directory where new migration scripts are created and read from. - The
migrationsCollection
field specifies the name of the collection that will be created or read from in your database, and it is where the current migration state is being held. To work with migrations, we can use the following commands:
branko@buzniç:~$ node /path/to/cli.js --create
branko@buzniç:~$ node /path/to/cli.js --run
branko@buzniç:~$ node /path/to/cli.js --revert
--create
creates a migration TypeScript file insidemigrationsDir
--run
is used to run all the unapplied migrations frommigrationDir
--revert
reverts the last successfuly processed migration
Below is an example output of the --create
migration command.
import { Migration, Database } from 'nest-arango';
export class Migration1679387529350 implements Migration {
async up(database: Database): Promise<void> {
return;
}
async down(database: Database): Promise<void> {
return;
}
}
Currently, there is no support for named migrations. The migration uses a timestamp to ensure our migrations will run in the order of their creation. Inside the migration script, we can define what collections, indexes, views, graphs, etc. will be created or dropped when the migration is applied/reverted.
import { Migration, Database } from 'nest-arango';
export class Migration1679387529350 implements Migration {
async up(database: Database): Promise<void> {
await database.createCollection('Users');
}
async down(database: Database): Promise<void> {
await database.collection('Users').drop();
}
}