Please, use @swarm-host/lair
from then @swarm-host
monorepo.
npm i lair-db --save-dev
Lair-db is a database written on TypeScript. Its main use-case is a test-mode for SPA-applications. Lair-db should be used with fake-server (like Pretender) and data-generator (like Faker.js).
Lair-db consists of two parts - Lair and Factories. Lair is a place where all data is stored. It has several methods which implement basic CRUD operations. Lair is a Singleton. Factories generate Records that are pushed to the Lair and create its initial state. Each Record has id
-field which value is auto incremented in the scope of factory. Every id
is a "stringified" number.
To get Lair instance you should use static method getLair
:
import {Lair} from 'lair-db';
const lair = Lair.getLair();
Factories are sub-classes of class Factory
:
import {Lair, Factory} from 'lair-db';
class UnitFactory extends Factory {
static factoryName = 'unit';
}
const factoryInstance = new UnitFactory();
Now we have a factory. Currently, it can't do any useful things. Every generated Record for this Factory will have only single property (id
). All properties for Records are factory's attributes with field
-decorator:
import {Lair, Factory, field} from 'lair-db';
import * as faker from 'faker';
class UnitFactory extends Factory {
static factoryName = 'unit';
@field()
get firstName() {
return faker.name.firstName();
}
@field()
get lastName() {
return faker.name.lastName();
}
}
const factoryInstance = new UnitFactory();
Our factory will create Records with fields id
, firstName
and lastName
. Since faker
returns truly random values we'll get really different Records. We declared firstName
and lastName
as getter
s that return some random values.
import {Lair, Factory, field} from 'lair-db';
import * as faker from 'faker';
class UnitFactory extends Factory {
static factoryName = 'unit';
@field()
get firstName() {
return faker.name.firstName();
}
@field()
get lastName() {
return faker.name.lastName();
}
@field()
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
const factoryInstance = new UnitFactory();
Records created with such factory will look like:
{
"id": "1",
"firstName": "Jim",
"lastName": "Raynor",
"fullName": "Jim Raynor"
}
Here fullName
is a result of concatenation fields firstName
and lastName
. This means that fullName
initially has such value. Iff you update Record's firstName
or lastName
, fullName
WON'T be updated automatically.
To create some Records you firstly need to register factory in the Lair:
lair.registerFactory(factoryInstance);
Here factoryInstance
is a factory described before. Static field factoryName
is a Record's type generated by this factory. There is a method called createRecords
to create some Records and put them in the Lair-db:
lair.createRecords('unit', 10);
Here we've created 10 Records of type unit
.
You can't create Records of unregistered types.
Method createRecords
MUST be used only for initial filling of Lair-db.
Records of different types may be linked one to another. There is a special way to describe such links. It's called 'relationships'. Let's say we have two factories for units and squads. One unit may be in the one squad and any squad may contain many units (typical one-to-many or many-to-one relationships):
import {Factory, field, hasOne, hasMany} from 'lair-db';
import * as faker from 'faker';
class UnitFactory extends Factory {
static factoryName = 'unit';
@field()
get name() {
return faker.name.findName();
}
@field()
squadName = '';
@hasOne('squad', 'units')
squad;
}
class SquadFactory extends Factory {
static factoryName = 'squad';
@field()
get name() {
return faker.hacker.abbreviation(); // just some random
}
@hasMany('unit', 'squad', {
createRelated: 4
})
units;
}
lair.registerFactory(new UnitFactory());
lair.registerFactory(new SquadFactory());
Fields unit.squad
and squad.units
are described as relationship-fields. Methods hasOne
and hasMany
take two arguments. First one is a related Records type and second one is a inverted property name. For squad
-factory we added new attribute called createRelated
. Lair uses it to know how many related records should be created "silently". In the example above we set that each squad
should have 4 units
. Let's try to create some squads:
lair.createRecords('squad', 4);
Lair will create 4 squads
and 16 units
(4 for each squad
). We still can create unit
records with lair.createRecords('unit', 2)
, however they won't be linked with any squad
by default.
Sometimes it is useful to update record after it's created. Factory has method afterCreate
for this case. It receives create Record as argument and must return it. Let's add this method to the unit
factory:
class UnitFactory extends Factory {
static factoryName = 'unit';
@field()
get name() {
return faker.name.findName();
}
@field()
squadName = '';
@hasOne('squad', 'units')
squad;
afterCreate(record) {
record.squadName = record.squad.name;
return record;
}
}
Here we update unit's property squadName
with real squad.name
. Method afterCreate
takes record with all related records created before it. You may update any own unit
property in the afterCreate
, but you can't update related records and unit
relationships. This means, that you can't do delete record.squad
or record.squad.name = '1234'
.
Lair allows to load predefined data. Method loadRecords
can be used for this. It takes two arguments - factory name and data-array itself:
lair.loadRecords('unit', [/* data */]);
This method has several requirements:
- Factory
unit
must be registered - Factory
unit
must have declared attributes with@field / @hasOne / @hasMany
, otherwise new records will be almost empty - Factory
unit
must have attributeallowCustomIds
to betrue
- Loaded records must have unique identifiers (
id
-field) - Related factories must be registered too
- Related records must be already loaded
Once all Factories are created (and registered) and Lair is filled with records you are ready to mock your back-end.
Every request to your backend represents one of the four operations with records - Create, Read, Updated or Delete. So, Lair has methods for each request-type.
Method createOne
is used to create new record in the Lair. It should not be used for Lair initialize and should be only for PUT/POST request handlers (depends on which request-type is used in your application).
createOne
takes two arguments. First one is a record type and second one is a data for new record:
lair.createOne('unit', {
name: 'Sarah Kerrigan',
squad: '1'
});
There are few important moments here. Firstly, we don't include id
. Lair will generate it. Secondly, value for squad
-field is an identifier for squad
-record. Only identifiers may be used as values for relationship-fields on create or update records. Thirdly, all related records must be already in the Lair. So, in this example record squad
with id 1
is already created.
Newly created unit
will be automatically added to the squad
with id 1
.
Lair
uses attribute value
as a default value for createOne
if it's not provided. Method Factory.field
allows overriding defaultValue
. It takes hash with two properties value
and defaultValue
. First one is same as usual "old" field-declaration. Second one is a value (not Function) that will be used in the createOne
if nothing will be provided for field.
class LogFactory extends Factory {
static factoryName = 'log';
@field({
defaultValue: 'info'
})
get type() {
return faker.random.arrayElement(['warn', 'info', 'error']);
}
@field()
message = '';
}
lair.registerFactory(LogFactory);
const newLog = lair.createOne('log', {message: 'msg'}); // no `type` provided
console.log(newLog); // {message: 'msg', type: 'info'} 'info' - default value for `type` was used
Method updateOne
is used to update some record in the Lair. It takes three arguments - record type, record id and new data:
lair.updateOne('unit', '1', {
name: 'Rory Swann',
squad: '2'
});
Here we update some fields for unit
with id 1
and change its squad to 2
(it must be in the Lair).
Method deleteOne
is used to delete some record from the Lair. It takes two arguments - record type and record id:
lair.deleteOne('unit', '1');
Record unit
with id 1
will be deleted and squad
where this unit was will be updated.
There are four methods to get record(s) from the Lair. getOne
is a first of them. It takes two arguments - record type and record id:
lair.getOne('unit', '1');
It returns record with all related data:
{
"id": "1",
"name": "Jim Raynor",
"squad": {
"id": "1",
"names": "Ravens",
"units": ["1", "2", "3", "4"]
}
}
This method is also used to get a single record. The main difference between methods is that getOne
uses id
to get record and queryOne
uses a callback:
lair.queryOne('unit', record => record.id === '1');
Method queryOne
will return first record for which the callback returns true.
Method getAll
returns all records of given type:
lair.getAll('unit');
Method queryMany
returns record of given type that the passed function returns true for:
lair.queryMany('unit', record => record.squad === '1' || record.squad === '2');
You should set value to null
(for hasOne
) or []
(for hasMany
) to drop some relationship for record:
lair.updateOne('unit', '1', {
squad: null
});
lair.updateOne('squad', '1', {
units: []
});
Related records will be updated automatically.
You may set createRelated
as a function:
class SquadFactory extends Factory {
static factoryName = 'squad';
@hasMany('unit', 'squad', {
createRelated() {
return faker.random.number({min: 1, max: 10});
}
})
units;
}
Now every created squad
will have 1 - 10 related units.
class ParentFactory extends Factory {
static factoryName = 'parent';
@hasMany('child', 'parent', {
createRelated: 2
})
children;
}
class ChileFactory extends Factory {
static factoryName = 'child';
@hasOne('parent', 'children')
parent;
afterCreate(record, extraData) {
console.warn(extraData); // <--- check this out
return record;
}
}
lair.registerFactory(ParentFactory);
lair.registerFactory(ChileFactory);
lair.createRecords('parent', 1);
lair.createRecords('child', 2);
Field extraData
is available in the dynamic fields and contains information about a parent factory that forces Lair to create some records of the child factory.
In the example above console.log
will be called 4 times. First two times it will output:
{
"relatedTo": {
"factoryName": "parent",
"recordsCount": 2,
"currentRecordNumber": 1
}
}
{
"relatedTo": {
"factoryName": "parent",
"recordsCount": 2,
"currentRecordNumber": 2
}
}
Here relatedTo
contains name of the parent-factory, records count of child factory that will be created and number of creating child-record. currentRecordNumber
isn't new record identifier, and it's just a sequence number. It will be dropped to 1
for each parent-record.
Last two times console.log
from the field
-attribute will out:
{
"relatedTo": {}
}
Field relatedTo
is empty because child-records are created standalone and not in the scope of the parent factory.
Decorators hasOne
and hasMany
take two arguments. However, you may set null
as second parameter. In this case records will be related in one way:
class SquadFactory extends Factory {
static factoryName = 'squad';
@hasMany('unit', null) units;
}
class UnitFactory extends Factory {
static factoryName = 'unit';
@hasOne('squad', 'units') squad;
}
Here squad
records have some related units. When some unit will be added to the squad its squad
-field won't be updated.
Good example of reflexive relations is a directories structure. Each directory may have many child-directories and one parent-directory. Lair-db allows you to declare such relationships:
class DirFactory extends Factory {
static factoryName = 'dir';
@field()
get name() {
return faker.internet.domainWord(); // any random name
}
@hasMany('dir', 'parent', {
reflexive: true,
depth: 3,
createRelated() {
return faker.random.number({min: 1, max: 3});
}
})
dirs;
@hasOne('dir', 'dirs')
parent;
}
lair.registerFactory(DirFactory);
lair.createRecords('dir', 1);
lair.getOne('dir', '1');
Factory Dir
will create records with a lot of related records. Each dir
will have 1 - 3 child-directories and 3 levels depth:
{
"id": "1",
"name": "wendy",
"dirs": [
{
"id": "2",
"name": "alysa",
"dirs": [
{
"id": "3",
"name": "tracy",
"dirs": [],
"parent": "2"
},
{
"id": "4",
"name": "mabelle",
"dirs": [],
"parent": "2"
}
],
"parent": "1"
}
],
"parent": null
}
Lair-db allows creating sequences of values. This means that you can create a time line like:
class TimelineFactory extends Factory {
static factoryName = 'timeline';
@sequenceItem(
new Date().getTime() - 24 * 3600 * 1000,
prevValues => prevValues.pop() + 5000
)
timestamp;
@field()
get val() {
return faker.random.number({min: 1, max: 100});
};
}
Every created record for this factory will have timestamp
-property greater to 5 seconds than previous. It's useful for graphs and metrics.
Factory.sequenceItem
takes two mandatory arguments - initial value (it will be set to record with id 1
) and function that calculates value for next record. This callback will get list with all previously generated values for this field.
Third argument is a POJO with options for sequence. Currently only one option is available. It's called lastValuesCount
. Its value determines how many items will be passed to the callback:
class TimelineFactory extends Factory {
static factoryName = 'timeline';
@sequenceItem(
new Date().getTime() - 24 * 3600 * 1000,
prevValues => prevValues.pop() + 5000, // prevValues will have two values (for id '2' it will have one value)
{lastValuesCount: 2} // <----
)
timestamp;
@field()
get value() {
return faker.random.number({min: 1, max: 100});
}
}
Use option lastValuesCount
if your sequence items depends on limited number of previous values.
New Factory may be created based on another Factory:
class Parent1Factory extends Factory {
static factoryName = 'parent1';
@hasMany('child', 'parent', {
createRelated: 5
})
children;
afterCreate(record) {
console.log('parent 1');
return record;
}
}
class Parent2Factory extends Parent1Factory {
static factoryName = 'parent2';
@hasOne('child', 'parent', {
createRelated: 1
}) children;
afterCreate(record) {
console.log('parent 2');
return record;
}
}
class ChildFactory extends Parent2Factory {
static factoryName = 'child';
}
IMPORTANT All parent-factories must be registered in the Lair BEFORE any record of child-factory is create.
There are some cases when you don't need to get all related to the record data. Let's go back to the example with squad
and units
(one squad
has many units
and one unit
belongs to only one squad
). We consider the cases:
Squad info is needed with units ids and not whole units data:
lair.getOne('squad', '1', {depth: 1});
// {id: '1', name: 'Ravens', units: ['1', '2', '3', '4']}
Squad info is needed without units at all:
lair.getOne('squad', '1', {ignoreRelated: ['unit']});
// {id: '1', name: 'Ravens'}
Here we have two options called depth
and ignoreRelared
. First one determines how deeply Lair should go to get data for needed record. Second one determines what factories should be ignored while Lair combines data for needed record. Important ignoreRelated
contains a list of factory names and not attribute names! Both depth
and ignoreRelated
may be used together.
Key ignoreRelated
also can be a boolean. true
is for case when all related factories should be ignored. false
is for case when no one related factory should be ignored (default behavior).
These options are very useful for cases with a lot of related records that may cause performance issues when Lair will collect them from internal store.