firerecord
v1.0.6
Published
🔥 ActiveRecord-style Object-Relational Mapper for Cloud Firestore
Downloads
6
Readme
FireRecord
🔥 ActiveRecord-style Object-Relational Mapper for Cloud Firestore. Inspired by TypeORM and pring.ts.
Installation
$ yarn add firerecord
Then initialize FireRecord with the Firestore instance:
import * as admin from 'firebase-admin';
import { initialize } from 'firerecord';
admin.initializeApp();
initialize(admin.firestore());
TypeScript configuration
Enable experimentalDecorators
option in tsconfig.json
.
"experimentalDecorators": true
Quick Start
With FireRecord, your model looks like this:
import { Entity, Field } from 'firerecord';
class User extends Entity {
@Field()
public name!: string;
@Field()
public gender!: string;
}
And your domain logic looks like this:
await User.create({
name: 'mu29',
gender: 'male',
});
// Alternatively, you can use User.find(id) if you know id.
const user = await User.findBy({
name: 'mu29',
});
if (!user) {
return;
}
await user.update({
gender: 'female',
});
await user.destroy();
Usage
In this section, we'll see 4 decorators in FireRecord and how to manage data.
Defining models
Every FireRecord model should extend the Entity
class. FireRecord automatically detects a Firestore collection name from the model class name like ActiveRecord. (We use pluralize package)
import { Entity } from 'firerecord';
class User extends Entity {
}
Field Decorator
To add Firestore Document property, you simply need to decorate an entity's property with a @Field
decorator.
import { Entity, Field } from 'firerecord';
class User extends Entity {
@Field()
public name!: string;
@Field()
public gender!: string;
}
You can set the property's default value when using the @Field
decorator.
import { Entity, Field } from 'firerecord';
class User extends Entity {
@Field()
public name!: string;
@Field({ default: 'male' })
public gender!: string;
}
Embedded decorator
Since there is no JOIN query in Firestore, it's a good idea to embed data from other models unless you need strong consistency. Use @Embedded
decorator to embed data from other models, and just put the property names you want to embed in the second parameter.
import { Entity, Field, Embedded } from 'firerecord';
class User extends Entity {
@Field()
public name!: string;
@Field({ default: 'male' })
public gender!: string;
}
class Article extends Entity {
@Field()
public title!: string;
@Field()
public content!: string;
@Embedded(User, ['name'])
public user!: User;
}
The article entity's data will looks like this:
{
id: 'Mbfc08V59aQCY4yWnz75',
title: 'My Great Post',
content: 'Awesome Content',
user: {
id: 'OOFh7DiDuEoSGSMu1OfQ',
name: 'mu29',
},
}
You can use the cascade
option with the @Embedded
decorator. In the example above, let's say you want update an article's user information.
const article = await Article.find('Mbfc08V59aQCY4yWnz75');
if (!article) {
return;
}
article.update({
user: {
name: 'some fancy name',
},
});
This will updates the user's name embedded in the article, but does not change the original user model's name. Putting the cascade option will make it works as expected.
class Article extends Entity {
@Field()
public title!: string;
@Field()
public content!: string;
@Embedded(User, ['name'], { cascade: true })
public user!: User;
}
It is also possible to embed an array of entities.
import { Entity, Field, Embedded } from 'firerecord';
class User extends Entity {
@Field()
public name!: string;
@Field({ default: 'male' })
public gender!: string;
}
class Tag extends Entity {
@Field()
public name!: string;
}
class Article extends Entity {
@Field()
public title!: string;
@Field()
public content!: string;
@Embedded(User, ['name'])
public user!: User;
@Embedded(Tag, ['name'])
public tags!: Tag[];
}
There is a difference between updating an embedded entity list and an embedded entity, but we will see it later in update section.
Filtery` decorator
How do you deal with complex queries, such as all posts from one user sorted by creation date? Firestore docs suggested a decent solution: Encode all data about the query into a single map field. It looks like this:
{
id: 'Mbfc08V59aQCY4yWnz75',
title: 'My Great Post',
content: 'Awesome Content',
user: {
id: 'OOFh7DiDuEoSGSMu1OfQ',
name: 'mu29',
},
createdAt: 1569903714364,
updatedAt: 1569903714364,
filterBy: {
user: {
OOFh7DiDuEoSGSMu1OfQ: 1569903714364,
}
},
}
So we implemented it, the @FilterBy
decorator.
import { Entity, Field, Embedded, FilterBy } from 'firerecord';
class Article extends Entity {
@Field()
public title!: string;
@Field()
public content!: string;
@Embedded(User, ['name'])
@FilterBy()
public user!: User;
@Embedded(Tag, ['name'])
@FilterBy()
public tags!: Tag[];
}
It will automatically create filterBy
property with entity's createdAt
value.
{
id: 'Mbfc08V59aQCY4yWnz75',
title: 'My Great Post',
content: 'Awesome Content',
user: {
id: 'OOFh7DiDuEoSGSMu1OfQ',
name: 'mu29',
},
tags: [{
id: 'c2OTYSydklJoQBNLL9TJ',
name: 'hashtag',
}, {
id: 'g4taIbpA79N5ECAcilZo',
name: 'test',
}],
createdAt: 1569903714364,
updatedAt: 1569903714364,
filterBy: {
user: {
OOFh7DiDuEoSGSMu1OfQ: 1569903714364,
},
tags: {
c2OTYSydklJoQBNLL9TJ: 1569903714364,
g4taIbpA79N5ECAcilZo: 1569903714364,
},
},
}
Nested decorator
Firestore sub-collections can be represented by @Nested
decorator.
import { Entity, Field, Embedded, FilterBy, Nested } from 'firerecord';
class Comment extends Entity {
@Field()
public content!: string;
}
class Article extends Entity {
@Field()
public title!: string;
@Field()
public content!: string;
@Embedded(User, ['name'])
@FilterBy()
public user!: User;
@Embedded(Tag, ['name'])
@FilterBy()
public tags!: Tag[];
@Nested(Comment)
public comments!: NestedEntityList<Comment>;
}
Create
If you've used ActiveRecord, you will be familiar with it.
const user = new User({
name: 'mu29',
});
await user.save();
const tag1 = await Tag.create({
name: 'hashtag',
});
const tag2 = await Tag.create({
name: 'test',
});
const article = await Article.create({
title: 'My Great Post',
content: 'Awesome Content',
user,
tags: [tag1, tag2],
});
Note that nested entities must be created separately.
const comment = await article.comments.create({
content: 'First Comment :)',
});
Find
Finding an Entity is also similar to ActiveRecord, but applying range filters came from TypeORM.
const user = await User.find('OOFh7DiDuEoSGSMu1OfQ');
const article = await Article.findBy({
title: 'My Great Post',
});
There are 6 operators: Equal
, LessThan
, LessThanOrEqual
, MoreThan
, MoreThanOrEqual
, and ArrayContains
. Here's the use case:
import { LessThan } from 'firerecord';
const article = await Article.findBy({
createdAt: LessThan(1570006534722),
});
Also, you can query the properties of an embedded entity.
const article = await Article.findBy({
user: {
name: 'mu29',
},
});
lazy
option
If you do not need to read the data, such as just updating an attributes, you can use the lazy option.
const article = await Article.find('Mbfc08V59aQCY4yWnz75', { lazy: true });
Where
There is also a where
function that allows you to retrieve a list of entities. There is a condition
option that we used before on find
and findBy
function.
import { MoreThan } from 'firerecord';
const articles = await Article.where({
conditions: {
createdAt: MoreThan(1566903714231),
},
});
Filter option is supported on the property that is decorated with @FilterBy
. Note that you can only use one filter.
const articles = await Article.where({
conditions: {
createdAt: MoreThan(1566903714231),
},
filter: {
user: 'OOFh7DiDuEoSGSMu1OfQ',
},
});
Pagination is supported by the cursor
option. It will returns the entities after the cursor using Firestore's startAfter
function.
const articles = await Article.where({
conditions: {
createdAt: MoreThan(1566903714231),
},
filter: {
user: 'OOFh7DiDuEoSGSMu1OfQ',
},
cursor: 'Mbfc08V59aQCY4yWnz75',
});
Finally, we also support the order
option, which is a bit complicated. Normally, it takes an array of strings where the first element is an property name and the second element is an direction type. (asc or desc)
const articles = await Article.where({
conditions: {
createdAt: MoreThan(1566903714231),
},
order: ['createdAt', 'desc'],
});
However, if we use the filter
option, it only accepts the direction type.
const articles = await Article.where({
conditions: {
createdAt: MoreThan(1566903714231),
},
filter: {
user: 'OOFh7DiDuEoSGSMu1OfQ',
},
order: 'asc',
});
Update
Since we use the merge option in Firestore when updating entity, all data (including the embedded entity) will be merged, not replaced.
await Article.update('Mbfc08V59aQCY4yWnz75', {
content: 'Updated Content',
user: {
name: 'injung',
},
});
// or
const article = await Article.find('Mbfc08V59aQCY4yWnz75', { lazy: true });
if (!article) {
return;
}
article.update({
content: 'Updated Content',
user: {
name: 'injung',
},
});
However, an embedded entity list will be replaced, not merged.
Destroy
For now, deleting an entity does not erase the embedded data.
await User.destroy('OOFh7DiDuEoSGSMu1OfQ');
// or
const user = await User.find('OOFh7DiDuEoSGSMu1OfQ', { lazy: true });
await user.destroy();
// Caution: the user data embedded in the article entity remains.
Nested Entities
Nested Entities can also use the create
, update
, find
, findBy
, where
, and destroy
functions we've seen so far!
Transaction
Pass an array of Entity#save
functions that you what to be processed at once as an argument to the runTransaction function.
import { runTransaction } from 'firerecord';
const user = await User.find('OOFh7DiDuEoSGSMu1OfQ');
const article = new Article({
title: 'My Great Post',
content: 'Awesome Content',
user,
});
user.name = 'New fancy name';
await runTransaction([
article.save,
user.save,
]);
Contributing
Bug reports and pull requests are welcome on GitHub.
License
The package is available as open source under the terms of the MIT License.