What is Astronomy?
The Astronomy 2.x package introduces the Model Layer for Meteor collections. It can also be named the Object Document Mapping system (ODM) or for people coming from relational database environments the Object-Relational Mapping system (ORM).
Leaving terminology aside, Astronomy gives you a possibility to define a document’s schema that includes field definitions, helpers, methods, events, validators and many more. As a result, programming is much easier and the amount of code you have to write much smaller. But a picture is worth a thousand words, so let’s take a look at a simple example.
Example
When fetching documents from Mongo collections, you get plain JavaScript objects without any logic. You have to validate values of objects’ properties, check what fields have changed, save only modified fields, transform values coming from forms, in every place you are playing with a document; a lot of things to do. Wouldn’t it be great if you could define some simple rules and leave everything else to framework? It’s actually possible thanks to Astronomy. But first let’s take a look at how your code would look like without using Astronomy.
var post = Posts.findOne(id);
// Assign values manually instead doing it automatically.
post.createdAt = new Date();
post.userId = Meteor.userId();
// Manually convert values coming from the form.
post.title = tmpl.find('input[name=title]').value;
post.publishedAt = new Date(tmpl.find('input[name=publishedAt]').value);
// Every time implement custom validation logic.
if (post.title.length < 3) {
// Implement an error messages system.
throw new Error('The "title" field has to be at least 3 characters long');
} else {
// Detect what fields have changed and update only those.
// Access collection directly.
Posts.update({
_id: post._id
}, {
$set: {
title: post.title,
publishedAt: post.publishedAt,
createdAt: post.updateAt
}
});
}
With Astronomy and defined schema your code would look like follows:
// Notice that we call the "findOne" method
// from the "Post" class not from the "Posts" collection.
var post = Post.findOne(id);
// Auto convert a string input value to a number.
post.title = tmpl.find('input[name=title]').value;
post.publishedAt = new Date(tmpl.find('input[name=publishedAt]').value);
// Check if all fields are valid and update document
// with only the fields that have changed.
post.save();
What approach is simpler? I think the choice is obvious.
For clarity, here is a sample schema that allows that. May seem to be a lot of code but have in mind that you write it only once.
import { Class } from 'meteor/jagi:astronomy';
const Posts = new Mongo.Collection('posts');
const Post = Class.create({
name: 'Post',
collection: Posts,
fields: {
title: {
type: String,
validators: [{
type: 'minLength',
param: 3
}]
},
userId: String,
publishedAt: Date
},
behaviors: {
timestamp: {}
}
});
Why should I use Astronomy?
There are many other packages that implement some of the functionalities present in Astronomy. I will try to point out here the main benefits of using Astronomy over other solutions, besides having many features that are listed in the Features section.
- Astronomy is highly modularized. This was one of the main principles when creating it. As a result, anyone can easily hook into almost every process that happens in Astronomy. Developers can create their own modules, behaviors and validators.
- It’s easy to learn and use. Astronomy does not reinvent the wheel. It takes the best from the tools you are already familiar with, not only from the JavaScript world, but also from other languages.
- When using Astronomy, you can easily replace three to five packages that you already use with a single one that follows the same pattern across all its modules. The main principle is simplicity.
- It follows quite different principles to do the job than other packages do. As a result, the amount of code you have to write to setup your classes and create application logic is significantly lower.
- There are many developers and companies that are already using Astronomy in production.
Here are some comments from developers using Astronomy:
If this package were around when I created SimpleSchema, I would have used it instead of creating SimpleSchema.
Eric Dobbertin, author of SimpleSchema
I love your package, it’s really great […] as RoR developer, this package is really exciting!
Anyway, very happy to have moved to astronomy, really like it we will release an app using it soon, so I will let you know.
I still don’t understand how this package is not getting more popular imho this package is better than simple-schema.
Amazing work, beautifully designed package! Anyone give this package few seconds and take a look at sources? If people will write code in such clean and modular way, world would be better! Kudos Jagi!
Amazing package indeed! Coming from a php MVC background, this package is a gift :). You rock!
Thanks - this has the makings of an amazing tool…. I’m hoping to replace Mesosphere, collection-hooks, collection-helpers, and a bunch of custom code - all with a cleaner code base!
History
The idea for creating a package for Meteor that would introduce a Model Layer emerged after creating several simple Meteor applications. I noticed that I was constantly repeating the same parts of a code to manage documents’ storage and validation. It was frustrating in comparison to what I’ve been accustomed to in the Doctrine library for PHP that I had used for many years.
This is why I’ve decided to create a Meteor package that would take the best from the Doctrine library. The first version was released in 2013 and was named Verin Model. It worked well in my projects, so I made it available to the community through the Meteorite package installer. However, I haven’t been promoting it anywhere so the number of users was limited.
In the late 2014, I decided to give it one more try and implement a much better package. A package that would be modular, would have all the features from the previous package and a few more additions. In the meanwhile, many developers tried to fill a gap of lack of model layer in Meteor with their packages. Some of them (e.g. SimpleSchema) had features that I was looking for, but were too complex to use. Some packages just focused on single features (Collection Hooks, Collection Behaviours, Collection Helpers). I didn’t like the idea of using many packages that followed quite different rules. I just wanted one modular tool that would fit all my needs. That’s why I’ve created Astronomy.
Why the name “Astronomy”?
As almost everything that is Meteor-related has some space-related name, this one couldn’t be an exception. The model layer in the MVC pattern is a description of real objects. And, the science describing objects in space is called Astronomy. The choice was quick.
Features
Astronomy is highly modularized. Some basic features comes with the base jagi:astronomy
package. Others have to be added as separate modules / packages. Here is a list of all the features with a short description.
Astronomy documents
Documents fetched from collections are not simple JavaScript objects but instances of classes you define using Astronomy.
Fields’ types
When defining a field list, you can specify their types like String
, Number
, Boolean
, Date
etc. Thanks to that it will automatically validate fields’ values.
Fields’ default values
On document initialization, you may want to set default values of some fields. In Astronomy it’s easy to do. You can also use functions to compute a default value.
Nested fields / classes
You can nest one or many class instances in a field.
Transient fields
You can define list of fields that won’t be stored in the database. You can use transient fields as normal fields.
Immutable fields
There are situations when you may want to mark a field as immutable so it won’t be possible to change field’s value once it has been saved into the database.
Document’s EJSON-ification
EJSON is an extension of JSON that supports more types. When sending documents from the client to the server over the DDP protocol (for instance in Meteor methods), they get stringified using the EJSON.strinigify()
function. Astronomy classes are EJSON compatible, so you can send them over the DDP.
Methods
You can define helpers, so your document is not only a data storage but a “live” thing. A dog can bark dog.bark();
and a user can greet you user.sayHello();
.
Events
Astronomy implements an events system that allows you to hook into many processes happening inside the package. For example, you can hook into the process of saving document. You may want to lowercase user’s nickname just before saving document. With Astronomy it’s a piece of cake. The events system also provides you a way to stop an event’s propagation and to prevent default behavior.
Getters and setters
Each Astronomy document has get()
and set()
methods. Thanks to them, you can get and set multiple fields at once and access nested fields in an easy way. For example you can call user.set('profile.email', 'example@mail.com')
to set email address in the profile object.
Modified fields
Thanks to the doc.getModified()
method you can access fields that had been modified from the last save.
Cloning document
It allows making copies of documents. You can automatically save cloned document or modify it before saving.
var copy = post.copy(); // Creates a new copy with an empty _id
copy.title = 'Foobar'; // Make changes before saving
copy.save(); // Save document
var anotherCopy = post.copy(true); // automatically saved
Reloading document
Sometimes after introducing some changes into your document, you may want to reload document to its new state from the database. For that task you can use the reload()
method.
Indexes
This feature allows you to define indexes that will be created on given fields / columns to speed up the process of fetching data.
Inheritance
When there are classes with similar schemas, sometimes it’s simpler to create a base class and extend it by adding only the features that differ.
Multiple classes in the same collection
You can store instances of many classes in the same collection. You just have to tell Astronomy which field will store the name of the class to use when creating the instance.
Validation
When saving a document, Astronomy will validate its values agains rules you defined. If yo haven’t defined any rules it at least validate types of fields. You can also validate a document without saving it.
Validation order
You can define the order in which validation will take place.
Mongo queries
You can perform insert
, update
, upsert
and remove
mongo queries directly on a class with all the benefits of Astronomy, like default fields’ values or validation User.update(id, {$set: {firstName: 'John'}});
.
Behaviors
Behavior is a nice way of reusing your code in more than one class. If you have similar features in two or more classes, you should consider creating a behavior for that feature. An example of a behavior is the Timestamp behavior which automatically sets createdAt
and updateAt
fields with the current date on every document save or update.
Timestamp behavior
The Timestamp Behavior adds two fields that store information about a document’s creation and update dates. Those fields will be automatically filled with the correct date. To use this behavior you have to add it to your project meteor add jagi:astronomy-timestamp-behavior
.
Slug behavior
The Slug Behavior adds a slug field for storing a URL friendly value of a chosen field. The text Tytuł artykułu
will be converted to tytul-artykulu
. The slug field can then be used in routing http://localhost:3000/post/tytul-artykulu
. To use this behavior you have to add it to your project meteor add jagi:astronomy-slug-behavior
.
Softremove behavior
The Softremove Behavior adds the softRemove()
method to your class which prevents a document from being removed. Instead, it will flag the document as removed and you will be able to omit such documents on documents fetch. To use this behavior you have to add it to your project meteor add jagi:astronomy-softremove-behavior
.
Getting started
First, you have to create a Meteor project:
meteor create myapp
Open the project’s directory and add the Astronomy package.
cd myapp
meteor add jagi:astronomy
You’re ready to go.
Creating the first class
We’ll start by showing the simplest possible class implementation.
import { Class } from 'meteor/jagi:astronomy';
const Posts = new Mongo.Collection('posts');
const Post = Class.create({
name: 'Post',
collection: Posts
});
As you can see, we’ve created the Mongo collection named Posts
and the Post
class. It’s good to keep this convention. The Posts
(plural) collection is a container for documents of the Post
(singular) class. We’ve provided two attributes: name
and collection
. The name
attribute is obligatory and it’s just an internal name of the class. The collection
attribute just tells our class in which collection instance our class should be stored.
Now, we can create an instance of the Post
class.
var post = new Post();
Our class is very simple and right now. It doesn’t have any fields, so let’s change that.
import { Class } from 'meteor/jagi:astronomy';
const Post = Class.create({
name: 'Post',
collection: Posts,
fields: {
title: String,
publishedAt: Date
}
});
Now, we have the class with the two fields: title
and publishedAt
. The type of the title
field is String
and the type of the publishedAt
field is Date
. Let’s create an instance of the class and fill it with some values.
var post = new Post();
post.title = 'Sample title';
post.publishedAt = new Date();
How do we save the document into database? Nothing simpler.
post.save();
Let’s assume that after a while, we want to change the post’s title.
// Notice that we call the "findOne" method from the "Post" class.
var post = Post.findOne({title: 'Sample title'});
post.title = 'New title';
post.save();
In the example above, we fetched previously saved document and modified its title. The save
method ensures that only the title of the document will be updated in the database.
Adding validation
Let’s define some validation rules in our class.
import { Class } from 'meteor/jagi:astronomy';
const Post = Class.create({
name: 'Post',
collection: Posts,
fields: {
title: {
type: String,
validators: [{
type: 'minLength',
param: 3
}, {
type: 'maxLength',
param: 40
}]
},
publishedAt: Date
}
});
We’ve modified the definition of the title
field. Now instead passing a field type, we pass an object. The object contains two properties: type
and validators
. The type
property is just the type of the field. The validator
property is a list of validators for the given field. We’ve defined only two validators: minLength
and maxLength
. New validation rules will be check during the save operation.
var post = new Post({
/* ... */
});
// Validate length of the "title" field.
post.save();
What’s next?
This is a brief introduction that covered only a tiny portion of Astronomy’s features. If you want to read more about Astronomy please take a look at the other sections in this documentation.
Changelog
You can find a changelog here.
Examples
The best way to understand Astronomy is learning by an example, that’s why there is an example git repository that you can clone and run to see Astronomy in action. The example repository shows usage of Astronomy with FlowRouter. I encourage you to take a look at the code to see how integration with form templates is done.
The Meteor.users collection
If you want to provide a schema for the Meteor.users
class then here is a minimal example of such definition. Of course, you could make it more detailed. However, Meteor takes care of checking data validity for the Meteor.users
collection, so you don’t have to do it one more time.
import { Class } from 'meteor/jagi:astronomy';
const UserProfile = Class.create({
name: 'UserProfile',
fields: {
nickname: String
/* Any other fields you want to be published to the client */
}
});
const User = Class.create({
name: 'User',
collection: Meteor.users,
fields: {
createdAt: Date,
emails: {
type: [Object],
default: function() {
return [];
}
},
profile: {
type: UserProfile,
default: function() {
return {};
}
}
}
});
if (Meteor.isServer) {
User.extend({
fields: {
services: Object
}
});
}
Upgrading to 2.0
There are several changes in API that were made in Astronomy 2.0. Let’s discuss each one.
ES2015 support
Astronomy 2.0 uses ES2015 syntax in every file, including modules loading system. So, you should get familiar with it before switching. You can import data from Astronomy package in the following way.
import { Class } from 'meteor/jagi:astronomy';
Here is a list of all objects that you can import:
- Class - for creating classes
- Module - for creating modules
- Enum - for creating ENUMs
- Type - for creating custom types
- Event - for creating custom events
- Behavior - for creating custom behaviors
- Validator - for creating custom validators
- Validators - validators list
- Field - field class, handy for modules authors
- ScalarField - field class, handy for modules authors
- ObjectField - field class, handy for modules authors
- ListField - field class, handy for modules authors
Creating class
V1
var User = Astro.Class({});
V2
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({});
export User;
Type definition
Now, instead using string as a type, we use type constructor function.
V1
fields: {
firstName: 'string'
}
V2
fields: {
firstName: String
}
Nested fields
The way how we define nested fields got simplified and now we use []
array operator to define fields storing multiple values.
V1
fields: {
address: {
type: 'object',
nested: 'Address'
},
phones: {
type: 'array',
nested: 'Phone'
}
}
V2
fields: {
address: Address,
phones: [Phones]
}
The “this” context in event
The this
context in event handler is not a document. Use event.target
to get a document. Use event.currentTarget
to get nested document.
events: {
beforeSave(e) {
const doc = e.currentTarget;
}
}
Fetching documents
Astronomy 2.0 does not transform documents coming from the collection. Now, you have to use class level find()
and findOne()
methods to retrieve transformed documents.
V1
var post = Posts.findOne();
var posts = Posts.find();
V2
var post = Post.findOne();
var posts = Post.find();
Document modification
In Astronomy 2.0 you don’t have to use set()
method to modify fields’ values. Now you can modify document freely and Astronomy will detect changes automatically. Methods like push()
, inc()
, pop()
have been removed.
V1
user.set('firstName', 'John');
user.push('phones', '+48 123 123 123');
V2
user.firstName = 'John';
// However, you can still write for nested fields.
user.set('address.city', 'San Francisco');
// You can push directly into the array.
user.phones.push('+48 123 123 123');
Validation
There is a lot of changes in how validators are defined and how to handle validation process. To learn more about that, please go to this section.
Saving
Now, you don’t have to write Meteor methods in client and server to save a document. You can just write doc.save()
on the client and the corresponding doc.save()
method will be called on the server.
Key concepts
Fields definition
In Astronomy we can define a class/schema and provide list of fields. Let’s examine how to do that.
Simple fields list
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: String,
createdAt: Date,
age: Number
}
});
In the example above, we passed an object with the fields’ names as keys and the fields’ types as values. There are several predefined types. You can choose from:
- String
- Number
- Boolean
- Date
- Object
- Mongo.ObjectID
You can also create your own types.
Characters allowed in field name
You can’t use the .
sign in the field name. Actually, it’s a good practice to only use letters, digits and _
, -
characters.
ENUM type
One example of custom type is ENUM. You can read more about what ENUMs are here. You can easily create ENUM types in Astronomy even when JavaScript does not have native support for it. Let’s take a look at the basic example of creating ENUM type.
import { Enum } from 'meteor/jagi:astronomy';
const Status = Enum.create({
name: 'Status',
identifiers: ['OPENED', 'CLOSED', 'DONE', 'CANCELED']
});
Now you can access each identifier in the following way.
Status.OPENED; // 0
Each identifier will represent successive numbers starting from 0.
- OPENED = 0
- CLOSED = 1
- DONE = 2
- CANCELED = 3
You may also want to provide non successive values, then you can define ENUM in the following way.
import { Enum } from 'meteor/jagi:astronomy';
const Status = Enum.create({
name: 'Status',
identifiers: {
OPENED: 1,
CLOSED: 2,
DONE: 4,
CANCELED: 8
}
});
If there is lack of value for some identifier, then it will receive successive number. Let’s take a look at the example.
import { Enum } from 'meteor/jagi:astronomy';
const Status = Enum.create({
name: 'Status',
identifiers: {
OPENED: 5,
CLOSED: null,
DONE: 15,
CANCELED: undefined
}
});
And values for each identifier will be.
- OPENED = 5
- CLOSED = 6
- DONE = 15
- CANCELED = 16
Usage
Now let’s take a look at how to use our Status
type in the class schema.
import { Class } from 'meteor/jagi:astronomy';
const Issue = Class.create({
name: 'Issue',
/* ... */
fields: {
status: {
type: Status
}
}
});
As you can see it’s quite straightforward. You can use this type as any other type. Benefit of having such a type is not only easy way of accessing identifiers but also automatic validation. So, Astronomy will not allow storing in the status
field any value that is not listed in the ENUM’s definition.
Getting values
You can get all values used to construct an ENUM. Remember that if there was a lack of value for some identifiers, the returned values will be the ones generated by Astronomy.
import { Enum } from 'meteor/jagi:astronomy';
const Status = Enum.create({
name: 'Status',
identifiers: ['OPENED', 'CLOSED', 'DONE', 'CANCELED']
});
Status.getValues(); // [0, 1, 2, 3]
const StatusBis = Enum.create({
name: 'Status',
identifiers: {
OPENED: 5,
CLOSED: null,
DONE: 15,
CANCELED: undefined
}
});
StatusBis.getValues(); // [5, 6, 15, 16]
Getting identifier
In some cases you may want to retrieve identifier for a number value that is stored in a field. Let’s take a look how to convert number value to identifier.
import { Enum } from 'meteor/jagi:astronomy';
const Status = Enum.create({
name: 'Status',
identifiers: ['OPENED', 'CLOSED', 'DONE', 'CANCELED']
});
var statusNumber = 0;
Status.getIdentifier(statusNumber); // "OPENED"
As you can see, it will return the "OPENED"
string. You can also get all identifiers.
Status.getIdentifiers(); // ["OPENED", "CLOSED", "DONE", "CANCELED"]
Non number values
You can also define non number ENUMs, however it’s not recommended.
import { Enum } from 'meteor/jagi:astronomy';
const Status = Enum.create({
name: 'Status',
identifiers: {
OPENED: 0,
CLOSED: 'asd',
DONE: true,
CANCELED: false
}
});
Union type
One example of custom type is union. Union allows a field to accept values of different type. Let’s say you want to have a field which can accept Number
or String
. You can easily create a union type in Astronomy that will allow that. Let’s take a look at the basic example.
import { Class, Union } from 'meteor/jagi:astronomy';
const StringOrNumber = Union.create({
name: 'StringOrNumber',
types: [String, Number]
});
const Item = Class.create({
name: 'Item',
fields: {
stringOrNumber: {
type: StringOrNumber
}
}
});
Now you can validate documents of this class.
const item = new Item();
item.stringOrNumber = 'abc';
item.validate(); // Valid.
item.stringOrNumber = 123;
item.validate(); // Valid.
item.stringOrNumber = false;
item.validate(); // Invalid.
You can also pass an optional casting function to the union definition.
const StringOrNumber = Union.create({
name: 'StringOrNumber',
types: [String, Number]
cast(value) {
if (typeof value !== 'string') {
return String(value);
}
return value;
}
});
And use it in the standard way.
const item = new Item({
stringOrNumber: false
}, {cast: true});
item.set({
stringOrNumber: new Date()
}, {cast: true});
Default values
If you need to provide more information than just the field’s type - let’s say a default value, then you can do so. Take a look at the following example:
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: {
type: String,
default: ''
}
}
});
var user = new User();
user.firstName; // '' - empty string
If we don’t provide a default value for a field, then it will be set to undefined
on document’s creation. We can’t use JavaScript objects for default values directly. If we want to do so, then we have to use function and return such an object. The function will be executed on document’s creation and returned value will be used as the default value.
NOTE: If you want to set a default value of a field to object (an array is also object) you should always use a function that returns such an object. This is important because values in JavaScript are passed by reference and we want every instance of the class to have its own copy of the object, not one that would be shared among all documents.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
address: {
type: Object,
default: function() {
return {};
}
}
}
});
Nested fields
MongoDB is a document-oriented database. This allows you to not only store plain values in fields also objects and arrays and Astronomy have a nice syntax for allowing that.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
address: {
type: Object,
default: function() {
return {};
}
},
phones: {
type: [String],
default: function() {
return [];
}
}
}
});
In this example, we’ve defined the address
field that can store any JavaScript object and the phones
field that can store arrays of strings. In the square brackets you can use any available type like: Number
, Date
, Object
, Boolean
.
Nested classes
In the previous example, we showed how to define a field to store a JavaScript object. But, what if we want to provide more schema for such a field. We may want to have city
and state
fields and check their validity.
import { Class } from 'meteor/jagi:astronomy';
const Address = Class.create({
name: 'Address',
/* No collection attribute */
fields: {
city: {
type: String
},
state: {
type: String
}
}
});
const User = Class.create({
name: 'User',
collection: Users,
fields: {
address: {
type: Address
}
}
});
As you can see, we can use class constructor as a type. It’s true for all the classes defined with Astronomy. They are automatically registered as a new type.
Now let’s try defining the User
class, that has the addresses
field for storing an array of addresses.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
collection: Users,
fields: {
addresses: {
type: [Address]
}
}
});
Transient fields
Some fields may be computed from the values of other fields instead of being persisted in the database. The good example is calculating age from a birth date. As the age changes during the time, the birth date is constant, so it’s why we should only store the birth date. The example below shows how to set the age
field as transient and calculate its value.
NOTE: To calculate the age from the birth date we used the afterInit
event. You will learn more about events in following sections of this documentation.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
birthDate: Date,
age: {
type: Number,
transient: true
}
},
events: {
afterInit(e) {
const doc = e.currentTarget;
var birthDate = doc.birthDate;
if (birthDate) {
var diff = Date.now() - birthDate.getTime();
doc.age = Math.abs((new Date(diff)).getUTCFullYear() - 1970);
}
}
}
});
Immutable fields
If you want some field to be immutable you should set the immutable
flag in the definition of a field. It won’t be possible to change field’s value once it has been persisted in the database. Let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
createdAt: Date,
immutable: true
}
});
var user = new User();
// It's possible to set a value.
user.createdAt = new Date('2015-09-14');
// It's possible to change a value as long as it was not save into database.
user.createdAt = new Date('2015-09-15');
user.save();
// Now setting a value will be denied.
user.createdAt = new Date('2015-09-16');
Optional fields
In Astronomy all fields are required by default. To mark a field as optional use the optional
property.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
address: {
type: Object,
optional: true
}
}
});
Not providing a value for such a field will not throw any validation error. However, when you provide value then it will be validated agains any rules you’ve defined.
Mapping fields
Sometimes you way want to map some field name stored in the collection into another field name. It may be a result of changes in the schema during a development of application. Let’s say we used to store phone number in the phoneNumber
field but after some time we decided to change the field name to phone
. Of course, we could just perform some query and just change field name. But there may be situations where we would like to connect two fields into one (which of course could also be done using some query) and we don’t want to mess with the current data in the database. Let’s take a look at the example how to map the phoneNumber
field into phone
.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
phone: {
type: String,
resolve(doc) {
return doc.phoneNumber;
}
}
}
});
And another example with joining the firstName
and lastName
fields into one fullName
.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
fullName: {
type: String,
resolve(doc) {
return doc.firstName + ' ' + doc.lastName;
}
}
}
});
As you can see in both examples, we perform fields mapping by providing the resolve()
method in the field’s definition. As the first argument, it receives raw data from a collection. Your responsibility is to return a new value for a field. It’s worth noting that, if we’re dealing with nested class, the the resolve method for such class will receive only data for a nested field.
Casting values
In the Setting and getting values section will described how to cast values being set in a document. Each field type comes with its own default casting function. Here are examples of how given values will be casted using default casting functions.
String
123 => "123"
true => "true"
false => "false"
{foo: "bar"} => {foo: "bar"}
Number
"123" => 123
true => 1
false => 0
{foo: "bar"} => {foo: "bar"}
Date
572137200000 => Date(1988, 1, 18)
"1988-02-18" => Date(1988, 1, 18)
"2/18/1988" => Date(1988, 1, 18)
true => true
false => false
{foo: "bar"} => {foo: "bar"}
Boolean
0 => false
1 => true
"false" => false
"FALSE" => false
"0" => false
"TRUE" => true
"abc" => true
{foo: "bar"} => {foo: "bar"}
As you can see, objects are not casted and only plain values goes through the casting function. There are also several nice tricks for easier casting of values coming from the forms, like the "FALSE"
string will be casted to the false
value.
The values undefined
and null
are not casted.
We also need to discuss how empty strings (""
) are casted to each type depending on whether a field is optional or required.
// Required fields:
item.set('number', '', {cast: true}); // Casts to 0
item.set('boolean', '', {cast: true}); // Casts to false
item.set('date', '', {cast: true}); // Does not cast
item.set('object', '', {cast: true}); // Does not cast
item.set('list', '', {cast: true}); // Does not cast
// Optional fields:
item.set('number', '', {cast: true}); // Casts to null
item.set('boolean', '', {cast: true}); // Casts to null
item.set('date', '', {cast: true}); // Casts to null
item.set('object', '', {cast: true}); // Casts to null
item.set('list', '', {cast: true}); // Casts to null
In most cases default casting functions will fit your needs, however there are situations when you would like to define custom casting function. Astronomy 2.3 allows defining custom casting functions. Let’s take a look at the example.
const pattern = {
year: Number,
month: Number,
day: Number
};
const User = Class.create({
name: 'User',
/* ... */
fields: {
birthDate: {
type: Date,
cast(value) {
if (Match.test(value, pattern)) {
return new Date(
value.year,
value.month,
value.day
);
}
return value;
}
}
}
});
In this example, when you try assigning an object with the “year”, “month”, “day” properties being numbers, the casting function will cast such an object into a date object.
Setting and getting values
Once you have defined a list of fields, you may want to set and get some values from a document. Let’s check how to set values of a document.
Setting values
There is nothing special about setting values of a document. You may do it in a way you would do it with any JavaScript object.
const user = new User();
user.firstName = 'John';
user.address.city = 'San Francisco';
However there is the set()
helper method that can help you with setting nested fields and multiple fields at once.
const user = new User();
user.set('address.city', 'San Francisco');
user.set({
firstName: 'John',
lastName: 'Smith'
});
You may also set values on document creation.
const user = new User({
firstName: 'John',
lastName: 'Smith',
address: {
city: 'San Francisco',
state: 'CA'
}
});
Pulling values from arrays
To modify data in Astronomy documents you don’t have to use the set()
method. The set()
method is only required when you want to perform some additional operation on a data being set like for example casting. It’s very common to remove elements from arrays, so we will provide two examples of how to implement it. In the first example we will use pure JS and in the second one the lodash library.
// Pure JS
const post = Post.findOne();
// Delete comment whose author is Jagi
const index = post.comments.findIndex((comment) => comment.author === 'Jagi');
post.comments.splice(index, 1);
post.save();
// Using lodash
const post = Post.findOne();
// Delete comment whose author is Jagi
_.remove(post.comments, (comment) => comment.author === 'Jagi');
post.save();
Casting values
Values being set on a document are not casted to the fields’ types by default, you have to force this behavior. The set()
function takes an options object as the last argument.
user.set({
firstName: 123 // Will be casted to the "123" string
}, {
cast: true
});
In the example above the 123
number will be converted to the "123"
string, if the type of the firstName
field is String
.
You can also cast values on document construction, by passing the options object as the last argument of a document constructor.
const user = new User({
firstName: 123 // Will be casted to the "123" string
}, {
cast: true
});
Cloning values
When setting values using the set
method or constructor all values will be cloned by default. In most cases it’s expected behavior as it makes sure that you don’t copy references, which might cause hard to find bugs. However, if you know what you’re doing and you want to improve performance of your application you can turn off this option.
const user = new User({
firstName: 'John'
}, {
clone: false // It's not needed to clone values being set as they are defined inline.
});
user.set({
lastName: 'Smith'
}, {
clone: false // It's not needed to clone values being set as they are defined inline.
}
Merging values
When setting value for a field, the old value will be overridden by a new one. It’s not always expected behavior in a fields of the object/class type. Let’s take the address
field as an example and try updating the state
field.
const user = new User({
address: {
city: 'New York',
state: 'CA'
}
});
const addressData = {
state: 'CA'
};
user.set('address', addressData);
In the example above an entire address
field will be overridden, also the city
field. To make it work we would have to tell Astronomy explicitly what field we want to update.
user.set('address.state', addressData.state);
In this example it will work as expected but sometimes we would just like to use syntax from the previous example and just merge objects instead overriding. In Astronomy 2.3, we’ve introduced the merge
option which allows that. Let’s take a look at the example.
const addressData = {
state: 'CA'
};
user.set('address', addressData, {
merge: true
});
Right now the address
field won’t be overridden and instead it will be merged with the object being set. We can also use the merge
option in the following scenario.
const userData = {
address: {
state: 'CA'
}
};
user.set(userData, {
merge: true
});
It will merge objects at all levels.
Default values
When you try to set some field’s value to undefined
using the set
method but the field has default value defined, it will using default value for this field (it won’t use default value if you’re doing direct assignment). You might want to turn off this option.
// Default value for the "address" field is "new Address()".
const user = User.findOne();
user.set('address', undefined); // It will set field to "new Address".
user.set('address', undefined, {defaults: false}); // It will set field to "undefined".
Getting values
Getting values is similar to setting them. We also have here the get()
helper method that helps with getting multiple and nested values.
const user = User.findOne();
user.firstName; // Get first name.
user.address.city; // Get nested field.
user.get('address.city'); // Get nested field with helper method.
user.get(['firstName', 'lastName']); // Get multiple fields.
Getting modified values
There are four methods that help with getting modified fields/values.
isModified([fieldName])
When called without arguments doc.isModified()
will tell you if a document was modified. However, you can pass a field name user.isModified('firstName')
to determine if a single field was modified. You can also pass nested field name user.isModified('address.city')
.
getModified([old])
Returns list of all fields that have been modified (including nested fields).
getModifiedValues([options])
Returns values of all modified fields as an object with keys being fields names and values being fields values. The method can take an optional options
object where you can specify if you want to retrieve old
values before modification. You can also retrieve raw
values of nested classes if they were modified.
user.getModifiedValues({ old: true, raw: true });
/*
{
address: {
city: "San Francisco",
state: "CA"
}
}
*/
getModifier()
Returns a modifier for modifications that were performed from the last document save.
const user = User.findOne();
user.firstName = 'John';
user.lastName = undefined;
user.getModified();
/*
{
$set: {
firstName: 'John'
},
$unset: {
lastName: ''
}
}
*/
Getting raw values
The raw()
method is responsible for getting plain values. You can use it to get a raw copy of the entire document, or a copy of a nested field. This means that even if a given field is defined as a nested Astronomy class, it will return a plain JavaScript object instead.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
name: String,
address: {
type: Address,
}
}
});
const user = User.findOne();
// Getting a plain value of the "address" field.
user.raw('address'); // { city: 'San Francisco', state: 'CA' }
// Getting a plain copy of multiple fields
user.raw(['name', 'address']); // { name: 'John Smith', address: { city: 'San Francisco', state: 'CA' } }
// Getting a plain copy of the entire document.
user.raw(); // { _id: 'uKqB7m2uZWi3zncR2', name: 'John Smith', address: { city: 'San Francisco', state: 'CA' } }
Storing Documents
In this section we will focus on documents storage. We will describe saving and removing documents.
Saving
MongoDB provides the insert
method that allows document inserting and the update
method that modifies an already stored document. In Astronomy we don’t need to call these methods directly. Astronomy documents are aware of their states, so we can replace insert
and update
methods with the one save()
method. Let’s take a look at an example showing how to insert a new document and update an existing one.
var user = new User();
user.firstName = 'John';
user.lastName = 'Smith';
user.save(); // Insert document.
user.firstName = 'Łukasz';
user.lastName = 'Jagodziński';
user.save(); // Update document.
As you can see, we’ve used the save()
method for both insertion and modification of a document. Every Astronomy document knows whether it should be inserted or updated, so you only need to call save()
.
Is a document new or not?
There maybe situations when you would like to know whether a document is new (not stored in collection yet) or not new (already stored in collection). Before version 2.3.4 you could use doc._isNew
property for that purpose. From version 2.3.4 this property is deprecated and will be removed in version 3.0. From know there is a new way of telling whether a document is new. Let’s take a look at the example below.
const Item = Class.create({
name: 'Item',
collection: new Mongo.Collection('items'),
events: {
afterInsert(e) {
const doc = e.target;
Item.isNew(doc);
}
}
});
As you can see, from now every class has the isNew()
method to which you pass a document that you want to check.
Server only call
By default, when you execute the save()
method on the client, it will perform the save operation in both environments client and server. Thanks to that you don’t have to create Meteor methods, however it’s highly recommended for security reason. But more about that in the Security section.
var user = new User();
user.save(); // Insert a document on the client and server.
There are situations when you don’t want to perform simulation on the client. The save()
method takes an object of options as the first argument. One of the options is simulation
which you have set to false
, to not perform client simulation.
var user = new User();
user.save({
simulation: false // Insert only on the server.
});
Updating only selected fields
The save()
method as the first argument can also take a list of options and one of them is fields
. By providing array of fields, we can force Astronomy to update only provided fields even when we modified more of them.
var user = User.findOne();
user.save({
fields: ['firstName', 'lastName'] // Update only first and last name.
});
It’s important to note here that the fields
option does not work with the insert operation.
Stopping validation on the first error
The save()
method can also take a list of options as the first argument and one of them is stopOnFirstError
. By default it’s set to true
, so it will stop further validation on the first validation error. However, if you want to get all validation errors, then you can set it to false
.
var user = User.findOne();
user.save({
stopOnFirstError: false // Don't stop on the first validation error.
});
Casting values on save
The save()
method can also take a list of options as the first argument and one of them is cast
. By default it’s set to false
, so it will not cast values using casting functions. There may be situations when you want to postpone casting values until the save operation. Let’s take adding a new phone number to the user document.
const user = User.findOne();
user.phones.push(phoneFormData);
As you can see in the example above, we’ve just used the push()
method of the Array object. It will not cast values from the phoneFormData
variable. In Astronomy to do so, we would have to use the set()
method, so we would need to know an index of the newly added phone document. Sample code could look like in the example below.
const user = User.findOne();
user.set(`phones.${user.phones.length}`, phoneFormData, {
cast: true
});
As you can see, it’s not perfect solution. So instead of casting values on assignment, we can postpone it to the save operation.
const user = User.findOne();
user.phones.push(phoneFormData);
user.save({
cast: true
});
Callback function
The save()
method takes a callback function as the last argument. The callback function will be called when document storage is finished or on when any error occurred on the client or server. The first argument of the callback function is error object and the second one is response from the server. On insert the response is ID of the inserted document, and on update it’s number of modified documents.
var user = new User();
user.save(function(err, id) {
});
Removing
Removing documents is as simple as saving them. It works the same way as the save()
method - it executes removal operation in both client and server. Let’s take a look at an example.
var user = User.findOne();
user.remove();
// Or with callback function.
user.remove(function(err, result) {
});
Fetching documents
Once we have Astronomy documents stored in a collection, we may want to retrieve them. The old/standard way of fetching documents does not change when using Astronomy. So calling Collection.findOne()
will just return plain JavaScript object. In Astronomy 1.0 it used to transform documents to Astronomy class instances. In Astronomy 2.0, we use Class.findOne()
to retrieve Astronomy documents. It works the same way as Collection.findOne()
but documents are transformed. Let’s take a look at an example.
import { Class } from 'meteor/jagi:astronomy';
import { Mongo } from 'meteor/mongo';
const Users = new Mongo.Collection('users');
const User = Class.create({
name: 'User',
collection: Users,
/* ... */
});
var user = new User();
user.save();
/* ... */
// Fetching document from the collection.
// It's not a plain JavaScript object, it's an instance of the User class.
var user = User.findOne(); // Get single document.
var users = User.find(); // Get cursor.
user.firstName = 'John';
user.save();
Find options
The same as in find
and findOne
methods from Mongo.Collection
, the Astronomy versions can take options as the second argument. Here is the list of extra options:
disableEvents
- by setting it to true, we turn offbeforeFind
andafterFind
events, about which we will talk more in the Events section.
var user = User.findOne({}, {
disableEvents: true
});
children
- if you are fetching documents from the class that has some child classes, then you can decide if you want to fetch children or not. You can read more about inheritance in the Inheritance section. By default all children are fetched however you can tell Astronomy how many levels deep it should look for children.
Parent.find(); // Fetch children, grand children and so on
Parent.find({}, {
children: false // Do not fetch children
});
Parent.find({}, {
children: 1 // Only fetch direct children
});
Parent.find({}, {
children: 2 // Fetch direct children and grand children
});
defaults
- option is set totrue
by default and might cause overriding some fields with default values on document update, if you’re subscribed only to some set of fields. In such case, it’s recommended to set it tofalse
which will fill fields that you’re not subscribed to withundefined
values instead of default ones. This options should be set tofalse
by default but because of the backward compatibility we can’t change it, so you have to be aware of this issue.
// both.js
const Post = Class.create({
name: 'Post',
collection: Posts,
fields: {
title: String,
tags: {
type: [String],
default() {
return [];
}
}
}
});
// server.js
Meteor.publish('posts', function() {
Post.find({}, {fields: {title: 1}});
});
// bad_client.js
Meteor.subscribe('posts');
const post = Post.findOne();
post.name = 'New name';
post.getModifier(); // {$set: {name: 'New name', tags: []}} - it will override tags
post.save();
// good_client.js
Meteor.subscribe('posts');
const post = Post.findOne({}, {defaults: false});
post.name = 'New name';
post.getModifier(); // {$set: {name: 'New name'}} - it will not override tags
post.save();
Helpers
By adding helpers to your class you can make a document to be alive. A user can user.greet()
you and a dog can dog.bark()
. Let’s take a look at the example of adding the fullName()
helper to the User
class.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: String,
lastName: String
},
helpers: {
fullName(param) {
var fullName = this.firstName + ' ' + this.lastName;
if (param === 'lower') {
return fullName.toLowerCase();
} else if (param === 'upper') {
return fullName.toUpperCase();
}
return fullName;
}
}
});
var user = new User();
user.firstName = 'John';
user.lastName = 'Smith';
user.fullName(); // Returns "John Smith"
A context this
in a helper is a document instance, so you can access other fields of a document. The fullName()
helper takes the firstName
and lastName
properties and join them with a space character and returns such a string. As you can see, we can also pass parameters to helpers.
Using helpers in Blaze templates
You can use Astronomy helpers in Blaze templates as normal helpers or properties. Let’s take a look at the example of printing a full name of a user.
<div>Full name: {{user.fullName}}</div>
You can also pass parameters to helpers:
<div>Full name: {{user.fullName "upper"}}</div>
Meteor methods (>= 2.2.0)
You can define Meteor methods directly in your class definition. Thanks to that, you can better organize your code. Executing method from the client will call it in both client and server. In previous Astronomy version you had to define helpers/methods and later Meteor methods that wrap these helpers. From version 2.2.0 you can minimize amount of code by putting all the logic in Meteor methods defined on the class level. Let’s take a look how to define them.
// user.js
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: String,
lastName: String
},
meteorMethods: {
rename(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
return this.save();
}
}
});
Now, we will execute Meteor method in the same way we execute Astronomy helpers.
// client.js
const user = User.findOne();
user.rename('John', 'Smith', (err, result) => {
});
As you can see, we pass three arguments to the rename()
method. The first two arguments are passed to the method. The last one is a callback function which will be execute after server response with the Meteor method result as the second argument of the callback function.
Server only methods
You might want to hide some method logic from the client and define Meteor method only on the server.
// user.js
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: String,
lastName: String
}
});
// server.js
User.extend({
meteorMethods: {
rename(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
return this.save();
}
}
});
In this case calling the rename
method on the client will throw error.
// client.js
const user = User.findOne();
// Throws "Uncaught TypeError: user.rename is not a function()".
user.rename('John', 'Smith', (err, result) => {
});
So how to call this method from the client? There are two special methods designed to help with that. They are callMethod()
and applyMethod()
. They work in similar way as Meteor.call()
and Meteor.apply()
// client.js
const user = User.findOne();
user.callMethod('rename', 'John', 'Smith', (err, result) => {
});
// or
user.applyMethod('rename', ['John', 'Smith'], (err, result) => {
});
The difference between these two method is that the second one takes an array of arguments.
The “this” context
The this
context in method is a document instance. Astronomy methods does not execute this.save()
automatically, so if you want to save changes made to the document, you have to execute the save()
method by yourself.
Method invocation object
Sometimes you may need to get method’s invocation object, giving you access to such properties like isSimulation
, userId
or unblock()
method which are available in Meteor methods in the this
context. To get the current invocation object you have to call DDP._CurrentInvocation.get()
.
import { DDP } from 'meteor/ddp-client';
User.extend({
meteorMethods: {
rename(firstName, lastName) {
const invocation = DDP._CurrentInvocation.get();
invocation.isSimulation;
invocation.unblock();
invocation.userId;
/* ... */
}
}
});
Events
Astronomy is equipped with a full-fledged events system including events propagation. There are several predefined events but behaviors and modules creators can create their own events. All events are defined in the class schema. Let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* */
events: {
beforeSave(e) {
/* Do something before saving document */
}
}
});
As you can see, an event handler function receives an event object as the first argument. It contains very useful data like event name (type
property) or event occurrence time (timeStamp
property) and more discussed below.
Accessing document
There are two properties in the event object through which you can access a document on which event occurred. There are target
and currentTarget
.
The e.target
property stores a document on which event was originally triggered. So if you execute user.save()
then in the beforeSave
event the target
property will the user
document.
The e.currentTarget
property stores a document on which event is actually executed. To better understand that let’s take our common example with User
and Address
classes. Let’s see the schema for the Address
class which is a nested class in the User
.
import { Class } from 'meteor/jagi:astronomy';
const Address = Class.create({
name: 'Address',
fields: { /* ... */ },
events: {
beforeSave(e) {
e.currentTarget;
}
}
});
Now, when you execute user.save()
it will first trigger the beforeSave
event on the user
document (all beforeSave
handlers for User
will be executed) and later it will trigger the beforeSave
event on each nested document including user.address
(all beforeSave
handlers for Address
will be executed). The e.currentTarget
property in the beforeSave
handler in the Address
class will point to the user.address
nested document and e.target
to the user
document.
Taking all that into account, it’s more likely that you will use e.currentTarget
than e.target
. The e.target
property is most often used to retrieve a “parent” document from the nested one.
The “trusted” property
The e.trusted
property is very important from the security point of view. You can read more about it in the Security section. In this moment, you have to know that the trusted
property indicates if e.g. the save operation was initiated on the server or not. If it was initiated on the server that it’s “trusted” if on the client then it’s not and we should validate user’s permissions.
Events propagation
Events propagation is a mechanism of propagating an event from the top document level down to the level of the most nested document. When you consider the User
class that has the address
nested property of the Address
type, then an event triggered on the level of the User
class will go down to the Address
class and down if the Address
class has any nested fields.
In any moment, we can stop execution of the further event handlers by calling the e.stopPropagation()
method on an event object passed to the event handler. Thanks to that, event will not propagate to the nested documents.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* */
fields: {
address: {
type: 'Address'
}
},
events: {
beforeSave(e) {
e.stopPropagation();
}
}
});
const Address = Class.create({
name: 'Address',
/* ... */
events: {
beforeSave(e) {
// This event will never get called because we stopped propagation.
}
}
});
There is also the e.stopImmediatePropagation()
method on the event object that not only stops propagation but also prevent from executing other events of the same type and on the same level.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* */
fields: {
address: {
type: 'Address'
}
},
events: {
beforeSave: [
function(e) {
e.stopImmediatePropagation();
},
function() {
// This event will never get called.
}
]
}
});
Preventing default
There are processes you may want to prevent from occurring. The example of such process may be preventing the save operation, however it’s more likely that you will use it in some custom behavior or module with your custom events. For the proof of concept let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* */
events: {
beforeSave(e) {
// Prevent document save.
e.preventDefault();
}
}
});
var user = new User();
// Document won't be saved.
user.save();
NOTE: It’s worth noting that the preventDefault()
method does not stop event propagation.
Events list
Initialization events
There are two events that are triggered on a document initialization:
beforeInit
afterInit
The beforeInit
event is triggered just after a new document instance is created. During this event all document’s fields are empty and you shouldn’t set or modify any field value. This event is mostly used by behaviors and modules.
The afterInit
event is triggered after a document is fully created and filled with data. You can freely modify fields’ values and do other changes.
Storage events
There are several storage events that are triggered on a document save, update and remove. Let’s take a look at what events are triggered on what operation and in what order.
Insert:
beforeSave
beforeInsert
- Actual document insertion is performed
afterInsert
afterSave
var user = new User();
u.save();
Update:
beforeSave
beforeUpdate
- Actual document update is performed
afterUpdate
afterSave
var user = User.findOne();
u.firstName = 'Smith';
u.save();
Remove:
beforeRemove
- Actual document removal is performed
afterRemove
var user = User.findOne();
u.remove();
Find events
There are two events being emitted when retrieving documents from collection using Class.find()
or Class.findOne()
:
beforeFind
afterFind
User.findOne(); // Both events triggered
An event object passed to the beforeFind
event handler contains two additional properties selector
and options
. They are two arguments passed to the findOne()
or find()
method. In an event handler you can modify selector or passed options. The softremove behavior is using it to limit fetching documents to only those that were not soft removed.
An event object passed to the afterFind
event handler contains three additional properties selector
, options
and result
. So, in this event handler you can additionally modify result of a find operation.
EJSON events
There are two events that maybe handy for behaviors and modules authors:
toJSONValue
fromJSONValue
These events are used to EJSONify documents and send them over DDP protocol.
Indexes
To speed up database access, we should create indexes on the fields we are searching on or sorting by. By default index is created only for the _id
field. We can define a single field index in a definition of the field or multi fields index in the indexes
property of the class schema. Let’s see examples of both.
Single field indexes
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
birthDate: {
type: Date,
// Define an index (ascending order) for the "birthDate" field.
index: 1
}
}
});
The value 1
under the index
property is an order in which index will be stored. In this case, it doesn’t matter if we use ascending (1
) or descending (-1
) order because MongoDB can easily iterate through the one key indexes in both directions. However it does matter in the case of the multi-key indexes. In the field definition we can only create single field indexes.
MongoDB supports several different index types including text, geospatial, and hashed indexes. If you want to use them you have to provide a type name instead of the 1 or -1 values. You can read about index types in the MongoDB documentation.
Let’s take a look at how to define the text
index.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
lastName: {
type: String,
// Define the "text" index on the "lastName" field.
index: 'text'
}
}
});
Multiple fields indexes
We define multi fields indexes under the indexes
property in the class schema. Let’s take a look at the example:
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: String,
lastName: String
},
indexes: {
fullName: { // Index name.
fields: { // List of fields.
lastName: 1,
firstName: 1
},
options: {} // Mongo index options.
}
}
});
The index name is fullName
. An index definition consists of two objects: list of fields and list of options.
A value of the fields
property is an object with the key-value pairs where the key is a field name and the value describes the type of index for that field. For an ascending index order on a field, specify a value of 1 and for descending index order, specify a value of -1. You can also use text, geospatial, and hashed types.
The options
property let’s you specify details of your index like index uniqueness. You can read more about available options in the MongoDB documentation.
Example of defining unique index:
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: String,
lastName: String,
email: String
},
indexes: {
email: {
fields: {
email: 1
},
options: {
unique: true
}
}
}
});
Inheritance
Creating a new class by inheriting from other class can give you several benefits:
- Each instance of a child class is also instance of a parent class
- You can store instances of different classes in the same collection
- You can fetch only documents of a given type even if they are stored in the same collection
At first, let’s check how to inherit a class.
import { Class } from 'meteor/jagi:astronomy';
const Parent = Class.create({
name: 'Parent',
fields: {
parent: String
}
});
const Child = Parent.inherit({
name: 'Child',
fields: {
child: String
}
});
Now, we can check if an instance of a child class is also an instance of a parent class.
var child = new Child();
child instanceof Child; // true
child instanceof Parent; // true
Many classes in the same collection
Previous example showed how to inherit class that does not have related collection in which documents are stored. Let’s create class that gets stored in a collection. We will inherit from it and store documents of both classes in the same collection.
import { Class } from 'meteor/jagi:astronomy';
import { Mongo } from 'mongo';
const Items = new Mongo.Collection('items');
const Parent = Class.create({
name: 'Parent',
collection: Items,
// Class discriminator.
typeField: 'type',
fields: {
parent: String
}
});
Child = Parent.inherit({
name: 'Child',
fields: {
child: String
}
});
Notice two things that have changed.
- We provided a collection object (
Items
) in the parent class definition - We provided a value of the
typeField
property in the parent class definition
The collection object is quite obvious, it’s where instances of our classes will be stored.
The typeField
property tells Astronomy what field name should be added to definitions of parent and child classes that determines type of a fetched object. To understand it better let’s take a look at the example.
var child = new Child();
child.type; // "Child"
var parent = new Parent();
parent.type; // "Parent"
As you can see in both classes Astronomy added the type
field which stores “Child” and “Parent” strings for Child
and Parent
parent classes accordingly. A value of this field will be stored with an instance and when fetching the given document from the collection, the transform function will automatically fetch an instance of proper class.
Executing parent helpers from the child ones
It’s not super easy and intuitive to call parent helper from the child one. We can’t just call super.helperOnParent([arguments]);
. Here is an example of how to achieve that using Astronomy:
Child.inherits({
name: 'Child',
helpers: {
helperName() {
Parent.getHelper('helperName').apply(this, arguments);
}
}
});
Extending class
There are situations, when we want to have some differences in a class (additional fields, helpers, methods or events) depending on an environment. For example, we may want to have some fields that are only available on the server. Let’s take a look at how we can extend a class to achieve that.
// Client and server.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: String,
age: Number
}
});
// Server only.
if (Meteor.isServer) {
User.extend({
fields: {
private: String
}
});
}
As you can see, we used the User.extend()
method to add server only field to the User
class. The only argument of the extend()
method is a class schema that extends the class.
Validation
Validators allows checking validity of fields’ values. For instance, we can check whether a value of a given field is a correct email string or if it matches a regular expression. By default Astronomy checks validity of fields’ types.
Validating document
To validate a document you have to call the validate()
method from the level of a document. The first argument of the validate method is optional list of options.
var user = new User();
user.validate({
fields: ['firstName'],
stopOnFirstError: false,
simulation: false,
cast: true
});
As you can, see the set of options is similar to the set of option in the save()
method.
fields
- List of fields to validate.stopOnFirstError
- Validation should stop after the first validation error. It’strue
by default.simulation
- Validation should be also simulated on the client. It’strue
by default.cast
- Values should be casted on validation. It’sfalse
by default.
Casting values of validation
There may be a need for postponing casting values until the validate or save operation. If you values are coming from the form, then number values will probably be in a form of the string. In such cases, you may want to cast values to numbers and later only throw validation errors when this number does not meet defined validation rules. Let’s take a look at the example usage.
var user = User.findOne();
user.phones.push(phoneFormData);
user.validate({
cast: true
}, function(err) {
// Do something with the error if any.
});
Callback function
The second argument in the validate()
method is a callback function, which behave in the same way as the one in the save()
method.
// On the client.
var user = new User();
u.validate(function(err) {
if (err) {
// Validation error.
}
});
The error object coming from the server may not always has be a validation error, so we need some mechanism of determining what error type it is. We can do it by checking the err.error
property of the error object. It should be equal "validation-error"
. The other way of checking it is using the ValidationError.is()
method. Let’s see example using both approaches.
import { ValidationError } from 'meteor/jagi:astronomy';
// On the client.
var user = new User();
u.validate(function(err) {
// First approach.
if (err && err.error === 'validation-error') {
}
// Second approach.
if (ValidationError.is(err)) {
}
});
Adding validators
There are two ways of adding validators to the class. You can define them on the class level on the field’s definition level. Let’s take a look at the example of both.
Validators on the field level
You can define validator on a field level to keep field definition together with its validators. It’s much more readable that way. You define validators for a field by providing the validators
property.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: {
type: String,
validators: [{
type: 'minLength',
param: 3
}]
}
}
});
As you can see, for the validators
property we provide array of objects, where each one has one mandatory attribute type
. In this example, we used the minLength
validator, which checks if a string is at least X characters long. The param
attribute tells how long a given text should be.
There are validators that does not take any param like type validators (string
, number
, data
) so it’s not mandatory for each validator. However, the minLength
validator requires this parameter to be passed.
Validators on the class level
This type of defining validators is much less used. This time, you define validators on the class level under the validators
property. Let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
validators: [
firsName: [{
type: 'minLength',
param: 3
}]
]
});
This situation is similar to previous one, however we only have here section dedicated to validation. We don’t provide any field properties beside validators data.
Reusing validators
Sometimes you may notice that you repeat the same set of validators over and over again. There is a possibility to reuse validators.
import { Class } from 'meteor/jagi:astronomy';
var min3max10 = [{
type: 'minLength',
param: 3
}, {
type: 'maxLength',
param: 10
}];
const User = Class.create({
name: 'User',
/* */
validators: {
firstName: min3max10,
lastName: min3max10
}
});
Function as a validator param
There are situations when you may want resolve param value on runtime as you may not know the actual param value on the schema definition time. For such cases there is a special resolveParam
validator property. You can provide function that will be resolved on validation. Let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
birthDate: {
type: Date,
validators: [{
type: 'lte',
resolveParam: function() {
let date = new Date();
return date.setFullYear(date.getFullYear() - 18);
}
}]
},
firstName: {
type: String,
validators: [{
type: 'maxLength',
resolveParam(args) {
return args.doc.lastName.length - 1;
}
}]
}
}
});
We have here two examples of usage of the resolveParam
function. In the birthDate
field, we used it for the lte
validator, which stands for less than or equal
. We just calculate at which date should at least given user be born to be 18 years old. So, this param depends on the current date.
In the second example, in the firstName
field we used the resolveParam
function in the maxLength
validator to tell that the firstName
has to be shorter than the lastName
(I know it’s bad example). So, we have to access document’s another field. We can do so, by accessing arguments object passed to the resolveParam
method. It contains such properties like.
args.doc
- Document being validatedargs.name
- Field name being validatedargs.nestedName
- Nested field name being validatedargs.value
- Current field’s value
Generating errors
There are several ways of generating an error message when validation fails. Of course, you can always use default error messages that come with validators. However, if you want something more application specific or when you need messages translation, it may be a good idea to generate custom error message. In this section, we will discuss all possible ways of the error message generation.
String validation message
The simplest and less flexible way of generating error message is passing a string as the message
property in the validator’s definition. Let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: {
type: String,
validators: [{
type: 'minLength',
param: 3,
message: 'The first name is too short!'
}]
}
}
});
As you can see, we passed the "The first name is too short!"
string and that’s the all we can do here. We can’t customize it. So, let’s do try generating error message dynamically.
Generating an error message
The another attribute of the validator is resolveError
, which has to be a function. It allows resolving error message on validation time. Let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
fields: {
firstName: {
type: String,
validators: [{
type: 'minLength',
param: 3,
resolveError({ name, param }) {
return `Length of "${name}" has to be at least ${param}`;
}
}]
}
}
});
As you can see, we have access to some parameters when using the resolveError
method. In our example we used name
and param
and composed error message using them. Remember to always return an error message. Beside two mentioned params there are more to work with.
className
- a class name of a document being validated,doc
- a document being validated,name
- a field name being validated,nestedName
- a nested field name being validate,value
- value of a field being validated,param
- param passed to the validator
Generating error message for required fields
When a field is required, then it has the required
validator assigned to it. In such case, you can’t just provided another required
validator for such a field and provide custom error message. In this section we will describe process of generating error messages for fields that already have some validators assigned.
For that purpose, there is special resolveError
property declared on the class level which can generate error message for any field in a class. Let’s take a look at the example below.
const i18n = {
messages: {
'firstName-required': 'Это обязательное поле.'
},
get(key) {
return this.messages[key];
}
};
const User = Class.create({
name: 'User',
fields: {
firstName: {
type: String
}
},
resolveError({nestedName, validator}) {
return i18n.get(`${nestedName}-${validator}`);
}
});
As you can see, the resolveError
property works the same way as its equivalent in field’s definition. So you can access the same properties of a field being validated. We just need to return error message from the function. We also introduced simple mechanism for i18n messages. You can improve it to add another languages.
Validators list
Here is a list of all validators predefined in Astronomy and information about params that they take.
string
Checks if value is a string.
{
type: 'string'
// No param.
}
number
Checks if value is a number.
{
type: 'number'
// No param.
}
integer
Checks if value is an integer.
{
type: 'integer'
// No param.
}
boolean
Checks if value is a boolean.
{
type: 'boolean'
// No param.
}
array
Checks if value is an array.
{
type: 'array'
// No param.
}
object
Checks if value is an object.
{
type: 'object'
// No param.
}
date
Checks if value is a date.
{
type: 'date'
// No param.
}
required
Checks if value is not null
or undefined
.
{
type: 'required'
// No param.
}
null
Checks if value is null
.
{
type: 'null'
// No param.
}
notNull
Checks if value is not null
.
{
type: 'notNull'
// No param.
}
length
Checks length of value. Works with any value having the length
property like arrays or strings.
{
type: 'length'
param: 5
}
minLength
Checks minimal length of value. Works with any value having the length
property like arrays or strings.
{
type: 'minLength'
param: 5
}
maxLength
Checks maximum length of value. Works with any value having the length
property like arrays or strings.
{
type: 'maxLength'
param: 5
}
gt
Checks if value is greater than param. Works with any value which casts to numbers like number, dates and strings.
{
type: 'gt'
param: 5
}
gte
Checks if value is greater than or equal to param. Works with any value which casts to numbers like number, dates and strings.
{
type: 'gte'
param: 5
}
lt
Checks if value is less than param. Works with any value which casts to numbers like number, dates and strings.
{
type: 'lt'
param: 5
}
lte
Checks if value is less than or equal to param. Works with any value which casts to numbers like number, dates and strings.
{
type: 'lte'
param: 5
}
Checks if value is a correct email address.
{
type: 'email'
// No param.
}
choice
Checks if value of the field is one of the values provided as a param.
fields: {
sex: {
type: String,
validators: [{
type: 'choice',
param: ['male', 'female']
}]
}
}
equal
Checks if value is equal to the one in a param.
{
type: 'equal'
resolveParam() {
return 'Should be equal to this value';
}
}
regexp
Checks if value matches regular expression passed as a param.
{
type: 'regexp',
param: /^[a-zA-Z0-9]+$/
}
and
Checks if all of validators passed as a param passes validation.
{
type: 'and',
param: [{
type: 'gt',
param: 6
}, {
type: 'lt',
param: 9
}]
]
or
Checks if any of validators passed as a param passes validation.
{
type: 'or',
param: [{
type: 'lt',
param: 6
}, {
type: 'gt',
param: 9
}]
]
every
Checks if any value in an array passes validation.
fields: {
tags: [String],
validators: [{
// Each tag in an array...
type: 'every',
param: [{
// ... has to be at least 3 characters long and...
type: 'minLength',
param: 3
}, {
// ... up to 40 characters long.
type: 'maxLength',
param: 40
}]
}]
}
has
Checks if a property is present in an object.
fields: {
address: {
type: Object,
validators: [{
// The non-schema address object has to have the "city" property.
type: 'has',
param: 'city'
}]
}
}
includes
Checks if an array or object contains a given value.
fields: {
tags: {
type: [String],
validators: [{
// The "tags" array has to contain the "test" tag.
type: 'includes',
param: 'test'
}]
}
}
Creating validators
The set of default validators may not be enough for you, so you can create your own validators. We will show the process of creating validator on the example of the maxLength
validator. Here is the whole code of the validator.
import _ from 'lodash';
import { Validator } from 'meteor/jagi:astronomy';
Validator.create({
name: 'maxLength',
parseParam(param) {
if (!Match.test(param, Number)) {
throw new TypeError(
`Parameter for the "maxLength" validator has to be a number`
);
}
},
isValid({ value, param }) {
if (!_.has(value, 'length')) {
return false;
}
return value.length <= param;
},
resolveError({ name, param }) {
return `Length of "${name}" has to be at most ${param}`;
}
});
We have two mandatory attributes. The first one is the name
attribute. Under this name the validator will be added to the global Validators
object.
The second mandatory attribute is the isValid
function. It should return a boolean value indicating if a value of a given field passes validation. As you can see, we also partially check validity of a value passed as an argument. We make sure that the length
property is present. The isValid
function receives several useful params as a first argument object.
- doc - a document being validated
- name - a field name being validated
- nestedName - a nested field name being validated
- value - value of a field being validated
- param - param passed to the validator
Using this information you can create very complex validation rules.
There is also the parseParam
function that check validity of a passed param. It’s not mandatory but it’s a good practice to check param and throw descriptive error for a developer that will be using a given validator. Thanks to that, it’s easier to track errors.
Another attribute is already described method resolveError
. It’s just responsible for generating default error message for a given validator. You need to compose an error message and return it.
More complex validators
There are validators like and
or or
which work in a little bit different way. As a param they take a list of validators. When you examine code of such validators you will notice that we don’t have the isValid()
method defined.
Validator.create({
name: 'and',
/* ... */
validate({
doc,
name,
value,
param: validators
}) {
_.each(validators, function(validator) {
// Get validator.
const validationFunction = Validators[validator.type];
// Execute single validator.
validationFunction({
doc,
name,
value,
param: validator.param
});
});
}
});
Instead, we have here the validate()
method which overrides the default validate()
method of a validator. Thanks to that we can run multiple validators passed as a param and decide what to do with a result of such validation. You can learn more about that just by examining code of such validators.
Global configuration
It’s possible to configure some Astronomy behaviors using global configuration object Astro.config
. You can access it by importing the Astro
object import { Astro } from 'meteor/jagi:astronomy'
.
You can also import config object directly import { config } from 'meteor/jagi:astronomy'
.
Here is the list of all available configuration options:
config.verbose = false;
- Turn off all warnings.config.resolving = false;
- Turn off resolving values for performance gain.config.defaults = false;
- Turn off setting default values on documents fetch.config.logs.deprecation = false;
// Turn off deprecation warnings.config.logs.nonExistingField = false;
// Turn off warnings about non existing fields.config.logs.classDuplicate = false;
// Turn off class duplication warnings.config.logs.typeDuplicate = false;
// Turn off type duplication warnings.
Example:
import { config } from 'meteor/jagi:astronomy';
// Do not log warnings.
config.verbose = false;
Security
Secured property
Every Astronomy class is “secured” by default and no operations from the client are allowed. It’s similar to removing the insecure
package from your Meteor project. In most cases it’s exactly what you want. However, you may turn off security and provide some rules in events. Everything depends on your programming style. Let’s talk about the class level secured
property in more details.
The “secured” property
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
// Turn off security for insert, update and remove operations.
secured: false
});
As you can see, we switched security off for every operation on this class. Now let’s see how to secure only certain operations.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
// Turn off security for update operations.
secured: {
update: false
}
});
In the example above, we turned off security only for update operations.
Now let’s see what will happen if you try insert something.
var user = new User();
user.save(); // Throw error: Inserting from the client is not allowed
Securing application using events
If you set the secured
property to false
then you have to be very careful about your application security. It will allow anyone to insert, update or remove documents from the client. In such situation, we have to secure a class by defining some security rules in events. Let’s take a look at the example.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
secured: false,
events: {
beforeUpdate(e) {
// Get a document being saved.
const doc = e.currentTarget;
// Check if a given user can update a document.
if (!RolesHelpers.isOwner(doc, Meteor.user())) {
throw new Meteor.Error(403, 'You are not authorized');
}
}
}
});
As you can see, in the beforeUpdate
event, we check if a given user can update a document. For that purpose, we use imaginary helper function RolesHelpers.isOwner()
which checks if a user is an owner of a document. In similar way, you have to write any other security rules. It’s very similar to what you can do in the allow/deny rules. Now, let’s see how to access different data.
INSERT:
// Allow/deny rules.
Posts.allow({
insert(userId, doc) {}
});
// Astronomy.
Post.extend({
events: {
beforeInsert(e) {
const userId = Meteor.userId(); // userId
const doc = e.currentTarget; // doc
}
}
});
UPDATE:
// Allow/deny rules.
Posts.allow({
update(userId, doc, fieldNames, modifier) {}
});
// Astronomy.
Post.extend({
events: {
beforeUpdate(e) {
const userId = Meteor.userId(); // userId
const doc = e.currentTarget; // doc
const fieldNames = doc.getModified(); // fieldNames
const modifier = doc.getModifier(); // modifier
}
}
});
REMOVE:
// Allow/deny rules.
Posts.allow({
remove(userId, doc) {}
});
// Astronomy.
Post.extend({
events: {
beforeRemove(e) {
const userId = Meteor.userId(); // userId
const doc = e.currentTarget; // doc
}
}
});
As you can see, in Astronomy events you can access all data that is accessible using allow/deny rules. You just define them in a little bit different way. And you are responsible for throwing Meteor.Error
with an error message.
Securing application using methods
Securing application using events may not work for every use case. Even MDG is not recommending securing your application using allow/deny rules beside situations where you really know what are you doing and you are very careful. MDG is recommending putting security rules in Meteor methods. That way you can split your application logic into separate methods. It allows you not only defining methods like post/insert
, post/update
, post/remove
but also post/publish
, post/rename
etc. So you have more control over an operation which you’re performing. Some security rules may work for update operation but not especially for the post/rename
operation which in theory is also update operation, but limited to changing only a single field’s value. Let’s see how to define some security rules using Meteor methods.
import { Class } from 'meteor/jagi:astronomy';
const Post = Class.create({
name: 'Post',
/* ... */
secured: true,
fields: {
title: String,
published: {
type: Boolean,
default: false
}
/* ... */
},
meteorMethods: {
rename(title) {
const invocation = DDP._CurrentInvocation.get();
// Check if a given user can rename a post.
if (!Permissions.canRenamePost(this, invocation.userId)) {
throw new Meteor.Error(403, 'You can not rename this post');
}
this.title = title;
this.save();
},
publish() {
const invocation = DDP._CurrentInvocation.get();
// Check if a given user can rename a post.
if (!Permissions.canPublishPost(this, invocation.userId)) {
throw new Meteor.Error(403, 'You can not publish this post');
}
this.published = true;
this.save();
}
}
});
Now let’s use these methods.
const post = Post.findOne();
post.rename('New post name');
post.publish();
Trusted property
Distinguishing between client and server initiated mutations
Astronomy’s event system automatically provides an extra property on every event called trusted
.
The trusted
property indicates if the current invocation of your event handler was triggered by a mutation requested by client-side code (should not be trusted and must pass security checks) or server-side code (presumably trusted).
If you omit the insecure
package from your app and do not add any allow/deny rules for the Mongo.Collection
managed by your Astronomy model, this allows you to implement all access control rules directly in Astronomy event hooks.
IMPORTANT NOTES
- Your event hooks must be defined on the server so they can run on the server. They may optionally also be defined on the client.
- If client-side code calls
save()
orremove()
on an Astronomy model, both the event hook that runs on the client and the event hook that runs on the server will havee.trusted
set tofalse
. - If server-side code calls
save()
orremove()
on an Astronomy model, the event hook will not run on the client, and the event hook that runs on the server will havee.trusted
set totrue
. - If you write your own custom Meteor Method on both the client and the server which internally calls
save()
orremove()
on an Astronomy model, your custom method will be run by Meteor on both the client and the server. This is how Meteor works. When your method is run on the server and callssave()
orremove()
, thee.trusted
property will be true since the call tosave()
orremove()
will have been initiated by the server-side version of your custom Meteor method.
Example implementation on insert
// server/models/car.js
import { Class } from 'meteor/jagi:astronomy';
const Car = Class.create({
name: 'Car',
/* ... */
events: {
beforeInsert: [
function checkUserID(e) {
const car = e.currentTarget;
if (!e.trusted) {
// Request to insert came from client-code.
// Run security checks.
if (/* Check doesn't pass */) {
throw new Meteor.Error(
"not-allowed",
"You don't have sufficient permissions to create a car."
);
}
}
else {
// Request to insert came from server-code.
}
},
function checkSomethingICareAbout(e) {
// Some other kind of check, possibly using `e.trusted` to determine
// if we need to run the check
}
]
}
});
Documentation to be included as part of the API doc of the event’s properties…
trusted: (boolean) false if the event was triggered by client-side code, true otherwise (link to further explanation of this property)
Rate limiting
Astronomy registers several Meteor methods which it uses internally. Like normal Meteor methods, they can be called from any client, and therefore should be safeguarded against flooding your server with method calls.
For that, you can use ddp-rate-limiter. You will have to define rules for the specific names of methods registered by Astronomy and adjust the message and time interval limits according to your specific application requirements.
Methods registered by Astronomy
/Astronomy/execute
/Astronomy/insert
/Astronomy/remove
/Astronomy/update
/Astronomy/upsert
/Astronomy/validate
Methods registered for softremove-behavior
/Astronomy/softRemove
/Astronomy/softRestore
An example rule with ddp-rate-limiter
// /server/lib/ratelimit.js
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
// Define method names you wish to create a rule for
const ASTRONOMY_METHODS = [
'/Astronomy/execute',
'/Astronomy/insert',
'/Astronomy/remove',
'/Astronomy/update',
'/Astronomy/upsert',
'/Astronomy/validate'
];
// Add new rule
DDPRateLimiter.addRule({
name(name) {
// Match methods registered by Astronomy
return _.contains(ASTRONOMY_METHODS, name);
},
// Limit per DDP connection
connectionId() { return true; }
}, 5, 1000); // Allow 5 messages per second
Building with Astronomy
The usefulness of Astronomy comes from its ability to help maintain the integrity of your data and also to organize the code related to your model layer. It can serve as basic documentation of your collections and the properties that documents in those collections have.
Before deep-diving into the API docs, let’s take a look at what a full-fledged model in Astronomy looks like to demonstrate most of the capabilities of Astronomy in one place and show how they can work together.
Let’s assume we are building a chat-type app that has a central model called “Conversations”. Here’s what that model might look like in <your app>/lib/models/conversation.js
.
// Flag for determining who (by default) can see what conversations.
const Visibility = Enum.create({
name: 'Visibility',
options: {
PUBLIC: 0,
PRIVATE: 1,
INVITE_ONLY: 2
}
});
// Sub-model for keeping track of a user
// that is participating in a conversation.
const Participant = Class.create({
name: 'Participant',
behaviors: ['timestamp'],
fields: {
userId: {
type: String
},
status: {
type: String
}
}
});
// Helper function for making sure that the
// request is coming from the server directly,
// or that it is coming from an authenticated user.
function mustBeLoggedIn(e) {
if (!e.trusted && !Meteor.userId()) {
// Anonymous client is trying to create a conversation.
throw new Meteor.Error(
'must-be-logged-in',
'You must have an account to create a new Conversation.'
);
}
}
// A Conversation represents one or more users who are talking together.
// This model contains all the "metadata" about the conversation, while
// actual messages are stored in the Message collection.
const Conversation = Class.create({
name: 'Conversation',
collection: new Mongo.Collection('conversations'),
behaviors: ['timestamp'],
fields: {
/**
* The userId of the user who created the conversation, or 'SYSTEM'
* if created by the app.
**/
ownerId: {
type: String,
default () {
return Meteor.userId() || 'SYSTEM';
},
immutable: true
},
/**
* A catch-all property to store custom extra info per conversation.
**/
metadata: {
type: Object,
default () {
return {};
}
},
/**
* Basic access-control flag.
**/
visibility: {
type: Visibility,
default: Visibility.PUBLIC
},
/**
* Users who are interested in this conversation.
**/
participants: {
type: [Participant],
default () {
if (Meteor.userId()) {
return [
new Participant({
userId: Meteor.userId()
})
];
}
else {
return [];
}
}
}
},
indexes: {
// make sure we can quickly find by ownerId
ownerIdIndex: {
fields: {
ownerId: 1
}
},
// make sure we can quickly find by visibility
visibilityIndex: {
fields: {
visibility: 1
}
}
},
events: {
beforeInsert: mustBeLoggedIn,
beforeUpdate: [
mustBeLoggedIn,
function mustBeAParticipant(e) {
if (e.trusted) {
return; // allow all updates made by the server
}
var conversation = e.currentTarget;
var userId = Meteor.userId();
var participant = _.find(
conversation.participants,
function(participant) {
return participant.userId === userId;
}
);
if (!participant) {
throw new Meteor.Error(
'must-be-participant',
'You must have already joined the conversation to do that.'
);
}
},
function visibilityCanOnlyBeSetByOwner(e) {
if (e.trusted) {
return; // allow all updates made by the server
}
var conversation = e.currentTarget;
var modifiedFields = conversation.getModified();
if (
_.contains(modifiedFields, 'visibility') &&
Meteor.userId() !== conversation.ownerId
) {
throw new Meteor.Error(
'must-be-owner',
'You cannot change the visibility of this conversation ' +
'since you are not its owner.'
);
}
}
],
beforeRemove: function disallowRemoveConversation(e) {
throw new Meteor.Error(
'cannot-remove-conversation',
'Conversations may not be deleted.'
);
}
},
helpers: {
/**
* Get all the messages for this conversation, sorted by most recent.
*/
messages() {
// assuming we have another Astronomy Model for our conversation's messages
return Message.find({
conversationId: this._id
}, {
sort: {
createdAt: 1
}
});
},
/**
* Convenience helper for sending a message to everyone in this conversation.
**/
send(content, callback) {
var message = new Message({
userId: Meteor.userId(),
conversationId: this._id,
content: content
});
return message.save(callback);
},
/**
* Adds a user to this conversation's participants.
**/
join(userId, callback) {
if (_.isFunction(userId)) {
callback = userId;
userId = null;
}
userId = userId || Meteor.userId();
if (!userId) {
return callback && callback(
new Error('Invalid user, cannot join conversation.')
);
}
return Conversation.update(this._id, {
$push: {
participants: new Participant({
userId: userId
})
}
}, callback);
}
}
});
Creating a conversation
var conversation = new Conversation();
conversation.save();
Sending a message to everyone in this conversation
var conversation = Conversation.findOne(<a conversation._id>);
conversation.send("I think I'm getting the hang of this...");
Joining a conversation as a logged in user
var conversation = Conversation.findOne(<a conversation._id>);
conversation.join(function(err){ ... });
Server adds a user to a conversation
var conversation = Conversation.findOne(<a conversation._id>);
conversation.join(<a user._id>);
Get all the messages for a conversation
var conversation = Conversation.findOne(<a conversation._id>);
conversation.messages();
Notes
This gets you pretty far along. In slightly more advanced code, this is what would change:
- The
mustBeLoggedIn
function would be placed into an namespace likeAuth.mustBeLoggedIn
so that it could be re-used across multiple models and unit tested. - The internals of the methods like send and join would be placed into separate utility functions so that they could be easily unit tested and the methods attached to the model would simply reference them.
Behaviors
A behavior allows extending a class schema with some extra functionality. Most of the time the functionality is a feature that is repeated over and over again for many classes. If you experience such situation, it may be a good idea to create custom behavior.
You can add behavior to the class in the following way.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
behaviors: {
behaviorName: {
/* Behavior options */
}
}
});
You may also want to add several behaviors of the same time to the same class. Here is how to do it.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
name: 'User',
/* ... */
behaviors: {
behaviorName: [{
/* 1. behavior options */
}, {
/* 2. behavior options */
}]
}
});
Let’s talk about predefined behaviors.
Timestamp
You can add the timestamp
behavior to your project by executing the following command in your Meteor project directory.
meteor add jagi:astronomy-timestamp-behavior
The timestamp
behavior adds two fields that store information about document’s creation and update dates. The behavior comes with following options. Options names are self explanatory.
behaviors: {
timestamp: {
hasCreatedField: true,
createdFieldName: 'createdAt',
hasUpdatedField: true,
updatedFieldName: 'updatedAt'
}
}
Let’s take a look at the behavior usage.
var post = new Post();
post.save();
post.createdAt; // A document creation date.
/* ... */
post.save();
post.updatedAt; // A document modification date.
Slug
NOTICE: This description is for v.3.0.0 of the slug behavior.
You can add the slug
behavior to your project by executing the following command in your Meteor project directory.
meteor add jagi:astronomy-slug-behavior
The slug
behavior adds a slug field for storing an URL friendly value of a chosen field. The slug field can be used in the routing for generating URLs http://localhost:3000/post/to-jest-test-polskich-znakow-aszclonz
. The behavior comes with following options.
behaviors: {
slug: {
// The field name from which a slug will be created.
fieldName: null,
// The helper name that generates a value for the slug-ification process.
helperName: null,
// The field name where a slug will be stored.
slugFieldName: 'slug',
// A flag indicating if we can update a slug.
canUpdate: true,
// A flag indicating if a slug is unique.
unique: true,
// A separator used for generating a slug.
separator: '-'
}
}
Let’s take a look at the behavior usage.
var post = new Post();
post.title = 'To jest test polskich znaków ąśźćłóńż';
post.save();
post.slug; // "to-jest-test-polskich-znakow-aszclonz"
The fieldName vs helperName
There are two possible ways of generating a slug: from a value of the field or a the value returned by the helper.
If we want to generate a slug from a single field then we have to provide the fieldName
option.
If we want to generate a slug from multiple fields or from a manually generated value then we have to provide the helperName
option.
NOTICE: You can’t use both ways of generating slug. You have to choose between the fieldName
option or the helperName
option.
Let’s take a look at the example of generating a slug using a helper.
import { Class } from 'meteor/jagi:astronomy';
const User = Class.create({
/* ... */
helpers: {
fullName() {
// Slug will be generated from the returned value.
return this.firstName + ' ' + this.lastName;
}
},
behaviors: {
slug: {
// You can't use the "fieldName option when using the "helperName" option.
// fieldName: 'lastName',
// The helper name that generates a value for the slug-ification process.
helperName: 'fullName'
}
}
});
Softremove
You can add the softremove
behavior to your project by executing the following command in your Meteor project directory.
meteor add jagi:astronomy-softremove-behavior
The softremove
behavior let’s you remove a document without deleting it from the collection. Instead it’s marked as removed. Removed documents can be excluded from displaying in a template. The behavior comes with following options.
behaviors: {
softremove: {
// The field name with a flag for marking a document as removed.
removedFieldName: 'removed',
// A flag indicating if a "removedAt" field should be present in a document.
hasRemovedAtField: true,
// The field name storing the removal date.
removedAtFieldName: 'removedAt'
}
}
Let’s take a look at the behavior usage.
var user = User.findOne();
// Sets the "removed" flag to true and saves it into the collection
user.softRemove();
Ok, but how to exclude removed document from being fetched. You have to use the find()
or findOne()
method defined on the class level.
// Get only not removed users.
var onlyNotRemovedUsers = User.find();
// Get all users.
var allUsers = User.find({}, {
disableEvents: true
});
NOTICE: In the first line, we call the find()
method from the User
class and in the second line from the Users
collection. The slug
behavior uses the beforeFind
event to modify selector that will cause fetching only non-removed documents.
You can also restore a soft removed document.
var user = User.findOne();
// Sets the "removed" flag to true and saves it into the collection
user.softRemove();
/* ... */
// Later, having the same reference to the document.
user.softRestore();
Community Behaviors
Here is a list of community created behaviors working with Astronomy 2.0.0+
If you’ve created package and you would like it to be listed here, please contact me.
Advanced usage
Custom types
In Astronomy you can create custom types. An example of such custom type is MongoObjectID
. Let’s take a look at the example of creating this type.
import Type from '../type.js';
import Validators from '../../validators/validators.js';
import { Mongo } from 'meteor/mongo';
Type.create({
name: 'MongoObjectID',
class: Mongo.ObjectID,
validate(args) {
Validators.mongoObjectID(args);
}
});
As you can see, we have here three properties. The first one is a name, which is mandatory. The second one is a class or constructor, that is used to create instances of our type. In this situation, it’s just Mongo.ObjectID
. But it could be also String
or any custom class/constructor that you created. The last property is the validate()
function which validates value stored in a field with our type used.
Now, let’s see how to use our type in a schema.
import { Class } from 'meteor/jagi:astronomy';
import { Mongo } from 'meteor/mongo';
const Post = Class.create({
name: 'Post',
/* ... */
fields: {
threadId: {
type: Mongo.ObjectID
}
}
});
We only pass our type constructor as the field’s type. That way Astronomy will know where to look for a type definition and what validation it should perform.
Writing behaviors
IN PROGRESS
Writing modules
IN PROGRESS