What is Astronomy?
The Astronomy 1.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, 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 a defined schema your code would look like follows:
var post = Posts.findOne(id);
// Auto convert a string input value to a number.
post.set('title', tmpl.find('input[name=title]').value);
post.set('publishedAt', tmpl.find('input[name=publishedAt]').value);
// Check if all fields are valid.
if (post.validate()) {
// Update document with with only the fields that have changed.
post.save();
}
What approach is simpler? I think the choice is obvious.
Why should I use it?
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. Consequently, 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 that 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 who already use it and are very happy that they switched to Astronomy. Here are some of their comments:
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.
Document transformation
Documents fetched from collections are not simple JavaScript objects but instances of classes you define using Astronomy.
Field types
When defining a field list, you can specify their types like string
, number
, boolean
etc. Thanks to this, when setting field’s value, it will be automatically casted to the given type.
Field 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 classes in a field. In this way, you can define types of fields and default values of nested objects and arrays.
Transient fields
You can define list of fields that won’t be stored in the database. You can use transient fields as normal fields and have all the benefits of Astronomy fields system.
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 methods, 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 a 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. They can also perform operation on nested properties user.set('profile.email', 'example@mail.com')
. Moreover, when using them, the beforeGet
, afterGet
, beforeSet
and afterSet
events are triggered and you can hook into the process of getting and setting a value.
Push, pop, inc operations
When working with array fields you can easily push()
and pop()
values from the array. Astronomy is smart enough to deduce what fields needs updating and execute minimal query to minimize database access. When working with number values you can use the inc()
method to increment the value of a number field.
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 already stored in the database. You can automatically save cloned document or modify it before saving var copy = post.copy();
.
Reloading document
Sometimes after introducing some changes into your document, you may want to reverse them. 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.
Direct collection access
You can perform insert
, update
, upsert
and remove
operations directly on a collection with all the benefits of Astronomy like defaulting field values or type casting.
Validation
The Validators module is responsible for making sure that the fields’ values in your document are in the proper format. For example, you can check whether an e-mail address is valid. To use this module you have to add it to your project meteor add jagi:astronomy-validators
.
Validation order
You can define the order in which validation will take place.
Simple validation
The Simple Validators module is an extension of the Validators module. It allows you to create validation rules in the form of a string instead of functions. However, this approach limits some functionalities. To use this module you have add it to your project meteor add jagi:astronomy-simple-validators
. You don’t have to add the jagi:astronomy-validators
module when using Simple Validators as it will be automatically included.
Relations
NOTE: This is an experimental module so use it at your own risk.
The Relations module allows you to define relations between documents of different classes. With this, we can easily fetch related documents. We can create one-to-one, one-to-many, and many-to-many relations. To use this module you have to add it to your project meteor add jagi:astronomy-relations
.
Query Builder
NOTE: This is an experimental module and you’re using it at your own risk.
The Query Builder module is an abstraction layer for accessing data in your database. To use this module you have to add it to your project meteor add jagi:astronomy-query-builder
.
Behaviors module
The Behaviors module 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 the 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.
You don’t need to use Behaviors module directly as long as you don’t want to create your own behavior. Instead, you’ll be using one of the modules listed below.
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
.
Sort behavior
The Sort Behavior helps with sorting documents. It delivers several useful methods to manage sorting like post.moveUp();
or post.moveBy(2);
. To use this behavior you have to add it to your project meteor add jagi:astronomy-sort-behavior
.
Softremove behavior
The Softremove Behavior adds the softRemove()
method to your class which prevents a document 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.
Posts = new Mongo.Collection('posts');
Post = Astro.Class({
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.
Post = Astro.Class({
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.set({
title: 'Sample title',
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.
var post = Posts.findOne({title: 'Sample title'});
post.set('title', 'New title');
post.save();
In the example above, we fetch a previously saved document and modified its title using the set
method. The save
method ensures that only the title of the document will be updated in the database.
Adding validation
Astronomy is highly modularized. By adding the jagi:astronomy
package to your project you’re only adding the basic functionalities. The validation feature is a separate module. To add it to your project you have to type in the console from your project’s directory:
meteor add jagi:astronomy-validators
NOTE: There’s also the Simple Validators package that’s much easier to use but has limited functionality.
Let’s define some validation rules in our class.
Post = Astro.Class({
name: 'Post',
collection: Posts,
fields: {
title: {
type: 'string',
validator: [
Validators.minLength(3),
Validators.maxLength(40)
]
},
publishedAt: 'date'
}
});
We’ve modified the definition of the title
field. Now instead passing a field type as a string, we pass an object. The object contains two properties: type
and validator
. 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
. Now, we’ll validate object before saving it.
var post = new Post({
/* ... */
});
if (post.validate()) {
post.save();
}
The validate
method will return false if any of the fields didn’t pass validation rules.
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 has two branches ironrouter and flowrouter showing usage of Astronomy with two routing packages IronRouter and FlowRouter. I encourage you to take a look at the code to see how integration with form templates is done. You can also take a look at working online example here. (Not possible anymore because of termination of the free hosting by MDG)
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.
UserProfile = Astro.Class({
name: 'UserProfile',
fields: {
nickname: 'string'
/* Any other fields you want to be published to the client */
}
});
User = Astro.Class({
name: 'User',
collection: Meteor.users,
fields: {
createdAt: 'date',
emails: {
type: 'array',
default: function() {
return [];
}
},
profile: {
type: 'object',
nested: 'UserProfile',
default: function() {
return {};
}
}
}
});
if (Meteor.isServer) {
User.extend({
fields: {
services: 'object'
}
});
}
Key concepts
Fields definition
Simple fields list
User = Astro.Class({
name: 'User',
/* ... */
fields: ['firstName', 'createdAt', 'age']
});
In the example above we’ve defined three fields. Their types haven’t been provided so they can take any value.
We can create an instance of the class and access fields.
var user = new User();
user.firstName; // null
user.createdAt; // null
Fields with types
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: 'string',
createdAt: 'date',
age: 'number'
}
});
In this example, 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
array
Default values
If you need to provide more information than just the field’s type – let’s say a default value, then you can pass an object with a field’s definition. Take a look at the following example:
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: {
type: 'string',
default: ''
}
}
});
var user = new User();
user.firstName; // '' - empty string
If we don’t provide a default value of the field, then it will be set to null
when the underlying document is created. For the default value, we can use a plain JavaScript object or a function. If we use a function, it will be executed on document creation the function’s 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 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.
User = Astro.Class({
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 the fields of documents but also objects and arrays. Astronomy provides two types (object
and array
) for storing object and array values. Let’s take a look at how to define fields with these types.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
'address': {
type: 'object',
default: function() {
return {};
}
},
'phones': {
type: 'array',
default: function() {
return [];
}
}
}
});
In this example, we’ve defined an address
field that can store objects and a phones
field that can store arrays of objects or arrays of any other value.
Default value of nested field
But, what if we want to define a default value for the nested object? We can do it by defining a nested class for a field.
Address = Astro.Class({
name: 'Address',
/* No collection attribute */
fields: {
city: {
type: 'string',
/* The default value of the nested field */
default: 'San Francisco'
},
state: {
type: 'string',
/* The default value of the nested field */
default: 'CA'
}
}
});
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
'address': {
type: 'object',
// The "address" field can store an instance of the Address class.
nested: 'Address',
default: function() {
return {};
}
}
}
});
The essential point when defining nested classes is providing a class name for the nested
property in the field definition. It may look awkward at first, but after using it for a while you may see the benefit of this approach.
Now let’s try defining the User
class, that has the addresses
field for storing an array of addresses.
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
'addresses': {
type: 'array',
// The "addresses" field can store multiple instances of the Address class.
nested: 'Address',
default: function() {
return [];
}
}
}
});
Inline nested class definition
It’s also possible to provide nested class definition as a value of the nested
property instead of a class name. Take a look at the example.
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
'address': {
type: 'object',
default: function() {
return {};
},
nested: {
name: 'Address',
fields: {
city: 'string',
state: 'string'
}
}
}
}
});
As you can see, we just provided a regular class definition. However this time we didn’t use the Astro.Class
method. Astronomy automatically calls this method for you with the provided definition. That’s great, but what if you want to create an instance of the Address
class? You don’t have a constructor. You can always access the constructo by using the Astro.getClass()
method.
var Address = Astro.getClass('Address');
var user = new User();
user.set('address', new Address());
Complex default value
A nested field can also have more complex default value.
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
'address': {
type: 'object',
nested: 'Address',
default: function() {
return {
city: 'Miami',
state: 'FL'
};
}
}
}
});
Array of plain values
What if we want to store multiple number values? In the nested
property for array fields we can provide not only a class name but also a simple type like number
or string
.
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
'phones': {
type: 'array',
nested: 'string'
},
'numbers': {
type: 'array',
nested: 'number'
}
}
});
Array of custom objects
There are situation when you would like to nest some objects in an array but not being instances of any class. In such case you just have to omit the nested
property. This way Astronomy will not require providing any particular type for a nested array.
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
'phones': {
type: 'array',
default: function() {
return [];
}
}
}
});
var user = new User();
user.push('phones', '888 888 888');
user.push('phones', {
name: 'cell'
number: '999 999 999'
});
As you can see, you can push either a plain JavaScript string or object.
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 an 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.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
birthDate: 'date',
age: {
type: 'number',
transient: true
}
},
events: {
afterInit: function() {
var birthDate = this.birthDate;
if (birthDate) {
var diff = Date.now() - birthDate.getTime();
this.set('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 the field. It won’t be possible to change the field’s value once it has been persisted in the database. Let’s take a look at the example.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
createdAt: 'date',
immutable: true
}
});
var user = new User();
// It's possible to set a value.
user.set('createdAt', new Date('2015-09-14'));
// It's possible to change a value as long as it was not save into database.
user.set('createdAt', new Date('2015-09-15'));
user.save();
// Now setting a value will be denied.
user.set('createdAt', new Date('2015-09-16'));
Optional fields
A field can also be marked as optional using the optional
attribute.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
address: {
type: 'object',
optional: true
}
}
});
Not proving a value for such field will not throw any error. However, when validating a document it will be taken into account. Validation module does not validate a field that is marked as optional and its value is null
.
Accessing fields
Getting value
The get()
method is responsible for getting a value from a document’s field. You can still access top level and nested fields directly. However there is an extra feature that comes from using the get()
method. It’s possibility to access a nested field with a string using the “.” notation.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
'address': {
type: 'object',
nested: 'Address'
}
}
});
var user = Users.findOne();
// Getting a value of the "address" field.
user.get('address'); // An instance of the Address class.
Getting multiple fields
You can also get multiple fields at once by providing an array of fields names.
user.get(['firstName', 'lastName', 'age']);
The code above will return an object of key-value pairs, where the key is a field name and the value is a field value.
Getting nested fields
The example below shows how you would access nested fields.
var user = new User();
user.set('address', {
city: 'San Francisco',
state: 'CA'
});
user.get('address.city'); // Returns "San Francisco".
As you can see, we’ve used the .
notation to access a nested field. The get
method will return the San Francisco
string.
Getting raw value
The raw()
method is responsible for getting a plain value from a document’s field. This means that even if a given field is defined as a nested Astronomy class, it will return a plain JavaScript object instead.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
'address': {
type: 'object',
nested: 'Address'
}
}
});
var user = Users.findOne();
// Getting a plain value of the "address" field.
user.raw('address'); // {city: 'San Francisco', state: 'CA'}
Getting multiple fields
You can also get raw values of multiple fields at once by providing an array of fields names.
user.raw(['firstName', 'lastName', 'age']);
The code above will return an object of key-value pairs, where the key is a field name and the value is a plain field value.
Getting all fields
Sometime you may want to get raw values of the all fields in a document. You just have to use the raw()
method without any argument.
user.raw(); // Get all values.
Getting nested fields
The example below shows how you would access nested fields.
var user = new User();
user.set('address', {
city: 'San Francisco',
state: 'CA'
});
user.raw('address.city'); // Returns "San Francisco".
As you can see, we’ve used the .
notation to access a nested field. The raw
method will return the San Francisco
string.
Documents modification
Set
You should always use the set()
method to set values. First, take a look at the example of a wrong assignment.
// ANTI-EXAMPLE
var user = new User();
user.age = $('#age').value; // WRONG! Never do that!
In the listing above, we’ve assigned value from the input to the age
field. The type of the age
field is number
but we assigned string value. It won’t be converted to the number as you would expect.
Now, take a look at the correct example.
var user = new User();
user.set('age', $('#age').value); // CORRECT!
The set()
method takes a field name as the first argument and field value as the second. A string value will be converted to the number during the assignment.
Setting multiple fields at once
You can also set multiple fields at once, by providing an object of key-value pairs, where the key is a field name and the value is a field value.
var user = new User();
user.set({
firstName: 'John',
lastName: 'Smith',
age: 30
});
Setting nested fields
Take a look at the following example, where we have a nested address
field without class definition.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
'address': {
type: 'object',
// No class provided.
default: function() {
return {};
}
}
}
});
var user = new User();
user.set('address.city', 'San Francisco');
As you can see, we’ve used the .
notation to access nested field. In this situation, the address
field will be filled with an empty object (default value) during the document creation. Later, we assign the San Francisco
string to the city
property that does not exist yet. But that’s not a problem, as long as the address
field’s value is already an object. Astronomy will add the city
property and properly assign the string.
Setting nested fields with specified class
But what about nested objects that have defined type. We have here two possible approaches. Let’s examine them.
User = Astro.Class({
name: 'User',
/* */
embedOne: {
address: {
nested: 'Address', // Class provided.
default: function() {
return {};
}
}
}
});
var user = new User();
// The same as before.
user.set('address.city', 'San Francisco');
// Use the "set" method from the "address" class.
user.address.set('city', 'San Francisco');
With the first solution, you’re already familiar.
It the second solution, we access the address
field directly and execute the set
method on it. It’s possible because the value of the address
field is an instance of the Address
class and this class also has the set
method. For Astronomy it doesn’t matter if we set value on the top level or nested object.
Setting fields on the initialization
When creating a new document, you can pass as the first argument of the constructor an object with values of the fields, including a document’s _id
. Thanks to that you can insert into collection objects coming from other sources.
var user = new User({
_id: 'P7gBYncrEPfKTeght',
firstName: 'John',
lastName: 'Smith'
});
user.save();
Push
You should always use the push()
method to push values into fields of array type. Let’s take a look at the following example:
Phone = Astro.Class({
name: 'Phone',
fields: {
number: 'number'
}
});
User = Astro.Class({
name: 'User',
/* ... */
fields: {
phones: {
type: 'array',
nested: 'Phone',
default: function() {
return [];
}
}
}
});
var user = new User();
user.push('phones', {
number: $('#phone').value
});
In the code above, we’ve pushed a value from the input field into the phones
field. The phones
field stores many phones number, where each one is an instance of the Phone
class. A value from the input is a string and will be converted to number because the push()
methods ensures that data stored in a document has been cast to its proper type.
Pushing into multiple fields at once
You can also push values into multiple fields at once. Instead passing a field name and field value you pass object with key-value pairs where the key is a field name and the value is the field’s value.
user.push({
'phones': {
number: '111222333'
},
'addresses': {
city: 'San Francisco',
state: 'CA'
}
});
Pushing into nested fields
Pushing values into nested fields is the same as setting a nested field. We use the “.” notation to access nested fields. Here’s an example:
user.push('nested.field', 'value');
Pop
You should always use the pop()
method to pop values from the fields of array type. Let’s take a look at the example. As of version 1.2.3, pop()
will return the item removed from the array or undefined if there is no such item.
Phone = Astro.Class({
name: 'Phone',
fields: {
number: 'number'
}
});
User = Astro.Class({
name: 'User',
/* ... */
fields: {
phones: {
type: 'array',
nested: 'Phone',
default: function() {
return [];
}
}
}
});
var user = Users.findOne();
user.pop('phones', 1);
In the example above, we’ve popped the most top value from the phones
field. You may wonder what the second argument of the pop()
method does. It determines if an element should be popped from the top (1
) or from the bottom (-1
) of the array. They correspond to the pop()
and unshift()
JavaScript methods accordingly.
Popping from multiple fields at once
You can also pop values from multiple fields at once. Instead passing a field name and 1
or -1
number you pass object with key-value pairs where the key is a field name and the value is 1
or -1
. Let’s take a look at an example:
user.pop({
'phones': 1, // Pop from the top.
'addresses': -1 // Pop from the bottom.
});
Popping from nested fields
Popping values from nested fields follows the same rules as functions previously described. We use the “.” notation to access nested fields. Let’s take a look at an example:
user.pop('nested.field', 1);
Pull
You should always use the pull()
method to pull values from the fields of array type. Let’s take a look at the example. As of version 1.2.3, pull()
will return an array of matched items. If there are no matches the empty array will be returned.
Phone = Astro.Class({
name: 'Phone',
fields: {
number: 'number'
}
});
User = Astro.Class({
name: 'User',
/* ... */
fields: {
phones: {
type: 'array',
nested: 'Phone',
default: function() {
return [];
}
}
}
});
var user = Users.findOne();
user.pull('phones', {
number: 123456789
});
In the example above, we’ve pulled all Phone
documents with a number 123456789
from the phones
field.
Pulling from nested fields
Pulling values from nested fields follows the same rules as functions previously described. We use the “.” notation to access nested fields. Let’s take a look at an example:
user.pull('nested.field', valueToPull);
Inc
You should always use the inc()
method to increment values of a field defined as a number type. Let’s take a look at an example:
User = Astro.Class({
name: 'User',
/* ... */
fields: {
age: {
type: 'number',
default: 0
}
}
});
var user = new User();
user.inc('age', 2);
In the code above, we’ve incremented a value of the age
field by 2. Astronomy will not allow modification of non-numeric fields using the inc()
method.
Incrementing multiple fields at once
You can also increment values of multiple fields at once. Instead passing a field name and a number you pass an object with key-value pairs where the key is a field name and the value is a number by which you want to increment the given field.
user.inc({
'age': -2,
'rank': 10
});
Incrementing nested fields
Incrementing nested fields is the same as setting a nested field. We use the “.” notation to access nested fields. Here’s an example.
user.inc('nested.field', -2);
Getting modified
An Astronomy instance is aware of a document’s state. It knows if a document is new or it’s already stored in the database. It also knows which fields have been modified from the last save operation. The getModfied()
method allows you to retrieve modified fields.
var user = Users.findOne();
user.firstName; // "Luke"
/* ... */
user.set('firstName', 'John');
user.getModified(); // Returns {firstName: "John"}
The method returns an object of key-value pairs where the key is a field name and the value is its new value. But what if we want to retrieve the old values, before the modification? You just have to pass true
as the first argument of the getModified
method.
user.getModified(true); // Returns {firstName: "Luke"}
Is document modified?
You can also check if a document has been modified using the isModified()
method. Note that this is not a reactive variable.
var user = Users.findOne();
user.isModified(); // false
user.set('firstName', 'John');
user.isModified(); // true
Storing Documents
Saving
In this section we will focus on the storage of documents. 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. Because Astronomy documents are aware of their states, we can replace both methods with the a single method save()
. 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.set({
firstName: 'John',
lastName: 'Smith'
});
user.save(); // Document insertion.
user.set({
firstName: 'Łukasz',
lastName: 'Jagodziński'
});
user.save(); // Document update.
As you can see, we’ve used the save()
method for both insertion and modification of a document. Every Astronomy document has a private _isNew
property that tells Astronomy if it should be inserted or updated.
Callback function
Because Meteor provides a way of passing a callback function as the last argument of insert()
and update()
methods, Astronomy does so as well by accepting a callback in the save()
method:
var user = new User();
user.save(function(err, id) {
});
Saving only certain fields
It’s also possible to save only certain fields of a document. You can pass a list of fields as the first argument of the save()
method.
var user = new User();
user.save(['firstName', 'lastName']);
Removing
Removing documents is as simple as saving them. Let’s take a look at an example:
var user = Users.findOne();
user.remove();
Callback function
Because Meteor provides a way of passing a callback function as the last argument of the remove()
method, Astronomy does it too.
var user = Users.findOne();
user.remove(function(err, count) {
});
Fetching documents
Once you bind an Astronomy class to a Collection, objects returned from that collection will automatically be converted to instances of the given class. Let’s take a look at an example:
Users = new Mongo.CollectionName = new Mongo.Collection('users');
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
firstName: 'string',
lastName: 'string'
}
});
var user = new User();
user.save();
/* ... */
// Fetching document from the collection.
var user = Users.findOne();
// It's not a plain JavaScript object, it's an instance of the User class.
user.set('firstName', 'John');
user.save();
As you can see, by binding the User
class with the Users
collection using the collection
property in the class definition, we automatically created a transformation function for the collection.
Fetching plain JavaScript objects
However, there are situation when you need a plain JavaScript object. You can do that by passing null
as the transform
option.
var user = Users.findOne({}, {
transform: null
});
user.save(); // TypeError: user.save is not a function.
Methods
By adding methods 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()
method to the User
class.
User = Astro.Class({
name: 'User',
/* ... */
fields: ['firstName', 'lastName'],
methods: {
fullName: function (param) {
var fullName = this.firstName + ' ' + this.lastName;
if (param === 'lower') {
return fullName.toLowerCase();
} else if (param === 'upper') {
return fullName.toUpperCase();
}
return fullName;
}
}
});
var post = new User();
post.set({
firstName: 'John',
lastName: 'Smith'
});
post.fullName(); // Returns "John Smith"
A context (this
) in a method is a document instance. You can access other fields of the document. The fullName()
method takes the firstName
and lastName
properties and join them with a space character and returns such string.
Using methods in templates
You can use Astronomy methods in templates as normal methods 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 methods:
<div>Full name: {{user.fullName "upper"}}</div>
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.
We can define events on the level of the class or on the global level. Let’s take a look at the example.
Define events on the class level
User = Astro.Class({
name: 'User',
/* */
events: {
'eventName': function(e) {
/* Do something on the event occurrence */
}
}
});
As you can see, an event handler function receives an event object as a first argument. For most events it only contains information about an event name and two useful functions: e.stopPropagation()
and e.preventDefault()
. The context this
of the event function is the document on which the event was triggered. We will talk more about them in next sections.
Define events on the global level
Now, take a look at how to define events on the global level:
Astro.eventManager.on('eventName', function(e) {
/* Do something on the event occurrence */
});
Events propagation
When you add an event handler for the same event on two levels: on the class level and on the global level, then which event handler will be called first? You have to look for the answer in the events propagation.
Here is the order in which events are triggered:
- Parent class event
- Child class event
- Global event
You will learn about inheritance in the following sections of this documentation. For know, you have to know that events are triggered in the order showed above.
In any moment, we can stop execution of event handlers to come by calling the stopPropagation()
method on an event object passed to the event handler. The example below shows how to stop execution of the global event during the execution of the event handler on the class level.
User = Astro.Class({
name: 'User',
/* */
events: {
'eventName': function(e) {
alert('Class event handler');
e.stopPropagation();
}
}
});
Astro.eventManager.on('eventName', function(e) {
// This event will never be called, because propagation was stopped.
alert('Global event handler');
});
Preventing default
There are processes you may want to prevent from occurring. The example of such process may be saving a document. You won’t probably use it on a regular basis. It’s more common to use it during the behaviors or modules implementation. Let’s take a look at the example:
User = Astro.Class({
name: 'User',
/* */
events: {
'beforeSave': function(e) {
if (true) {
e.preventDefault(); // Prevent saving a document.
}
}
}
});
var user = new User();
user.save(); // The document won't be saved.
In the beforeSave
event handler, we used the preventDefault()
method on the event object. Our condition in the if
statement is always true
so the operation will always be prevented. In a real use case you would probably change it to depend on a document state.
NOTE: It’s worth noting that the preventDefault()
method does not stop events propagation.
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
afterInsert
afterSave
Update:
beforeSave
beforeUpdate
afterUpdate
afterSave
Remove:
beforeRemove
afterRemove
You can prevent each operation using the preventDefault()
method on the event object passed to the event handler as the first argument.
Soft remove behavior events
There are also two events defined in the soft remove behavior:
beforeSoftRemove
afterSoftRemove
When they are triggered and what you can do using them will be described in the section regarding the soft remove behavior.
Direct database access events
There are also two event defined for the direct database access:
beforeFind
afterFind
When they are triggered and what you can do using them will be described in the section regarding the direct database access.
Modification events
There are several storage events that are triggered on a document modification. Methods that modifies a document are: set()
, inc()
, push()
and pop()
. Let’s take a look at what events are triggered on what operation and in what order.
The set()
method:
beforeChange
beforeSet
afterSet
afterChange
The inc()
method:
beforeChange
beforeInc
afterInc
afterChange
The push()
method:
beforeChange
beforePush
afterPush
afterChange
The pop()
method:
beforeChange
beforePop
afterPop
afterChange
You can prevent each operation using the preventDefault()
method on the event object passed to the event handler as the first argument.
Data passed in the event object
Event objects passed to the modification events handlers contain the data
property that stores additional information about the event. Let’s examine what does each event object hold.
The beforeChange
and afterChange
events in all methods.
e.data.fieldName
- a field name being modifiede.data.operation
- an operation name:set
,inc
,push
,pop
The beforeSet
and afterSet
events in the set()
method:
e.data.fieldName
- a field name being modifiede.data.setValue
- a field value being set
The beforeInc
and afterInc
events in the inc()
method:
e.data.fieldName
- a field name being modifiede.data.incValue
- an incrementation amount of a modified field
The beforePush
and afterPush
events in the push()
method:
e.data.fieldName
- a field name being modifiede.data.pushValue
- a value being pushed into the array
The beforePop
and afterPop
events in the pop()
method:
e.data.fieldName
- a field name that is being modifiede.data.popValue
- a value being popped from the array
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.
Data passed in the event object
An event object passed to the initialization events handlers contain the data
property that stores fields values being set on an instance being initialized.
User = Astro.Class({
name: 'User',
/* ... */
events: {
afterInit: function(e) {
console.log(EJSON.stringify(e.data));
}
}
});
var u = new User({
firstName: 'John',
lastName: 'Smith'
});
On the console the "{firstName: 'John', lastName: 'Smith'}"
text will be printed.
Other events
There are other events that mostly used in behaviors and modules that will be discussed in the following section. However, we will list theme here:
toJSONValue
fromJSONValue
initDefinition
initSchema
initClass
beforeGet
afterGet
Validators module events
There is one event (validationError
) defined in the jagi:astronomy-validators
module. Thanks to that event you can create your custom validation error message. You can read more about this event in the section regarding validation.
Indexes
To speed up the database access, we should create indexes on the fields we are searching on or sorting by. By default index is created only on 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
User = Astro.Class({
name: 'User',
/* ... */
fields: {
birthDate: {
type: 'date',
index: 1 // Define an index (ascending order) for the "birthDate" field.
}
}
});
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.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
lastName: {
type: 'string',
index: 'text' // Define the "text" index on the "lastName" field.
}
}
});
Multiple fields indexes
We define multi fields indexes under the indexes
property in the class schema. Let’s take a look at the example:
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: 'string',
lastName: 'string'
},
indexes: {
fullName: {
fields: {
lastName: 1,
firstName: 1
},
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:
User = Astro.Class({
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 two 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
At first, let’s check how to inherit a class:
Parent = Astro.Class({
name: 'Parent',
fields: {
parent: 'string'
}
});
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
We didn’t provide any collection object in class definitions. Let’s change it, so that we will be able to store instances of both classes in such collection.
Items = new Mongo.Collection('items');
Parent = Astro.Class({
name: 'Parent',
collection: Items,
typeField: 'type',
fields: {
parent: 'string'
}
});
Child = Parent.inherit({
name: 'Child',
fields: {
child: 'string'
}
});
Notice two things:
- 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 saved in.
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.
NOTE: In previous versions of Astronomy, the typeField
field was _type
and you weren’t able to change it. However in 1.0.0, you can freely decide how to name it.
Extending class
There are situations, when we want to have some differences in a class (additional fields, 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
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: 'string',
age: 'number'
}
});
// Only server
if (Meteor.isServer) {
User.extend({
fields: {
updatedAt: 'date',
createdAt: {
type: 'date',
default: function() {
return new Date();
}
}
}
});
}
As you can see, we used the User.extend()
method to add server only fields to the User
class. The only argument of the extend()
method is a class schema that extends the class.
Validation
The validators module 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. You can add this module to your Meteor project using the following command.
meteor add jagi:astronomy-validators
Validating document
The heart of the validation is the validate()
method. It can be called with different sets of arguments causing different effect. Here is the list of allowed sets of arguments.
validate()
- validate all fields and stop after the first errorvalidate(false)
- validate all fields and do not stop after the first errorvalidate(fieldName)
- validate a single fieldvalidate(arrayOfFieldsNames)
- validate multiple fields and stop after the first errorvalidate(arrayOfFieldsNames, false)
- validate multiple fields and do not stop after the first error
The validate()
method returns true
if validation succeeded and false
if there was any error. Let’s take a look at the example usage.
var user = new User();
if (user.validate()) {
user.save();
}
In the first step, we’ve created a new user document. In the next line, we check a validity of the document. If it’s valid then we save it.
Validation on the server
We should validate a document on both client and server. Let’s say, we have a form template that has some events and helpers that create a new document. We can validate such document and display validation errors in the form. However, we can’t trust validation on the client. We should always send a given document to the server and repeat validation. We should also send errors back to the client if there’re any. Let’s take a look at the example of Meteor method that performs validation and returns errors back to the client.
Meteor.methods({
'/user/save': function(user) {
if (user.validate()) {
user.save();
return user;
}
// Send errors back to the client.
user.throwValidationException();
}
});
If the validation haven’t succeeded, then we send validation errors back to the client using the throwValidationException()
method. Now, take a look at the example usage of this method.
Template.Form.events({
'submit form': function() {
var user = this;
Meteor.call('/user/save', user, function(err) {
if (err) {
// Put validation errors back in the document.
user.catchValidationException(err);
}
});
}
});
In the context of the Form
template we have our newly created document that was filled with values coming from the form fields. We pass the used
document as parameter of method. In the callback function, we check if there are any server validation errors. We put these errors back in the document using the catchValidationException()
method.
Optional fields
As described in the Optional fields section, if a field is marked as optional
then it won’t be validated if its value is null
.
Adding validators
There are two ways of adding validators to the class. You can define them on the level of the class or on the level of the field definition. Let’s take a look at the example of both.
Validators on the class level
Validators of the class level have to be defined under the validators
property in the class schema.
User = Astro.Class({
name: 'User',
/* ... */
validators: {
firstName: Validators.minLength(3)
}
});
As you can see, we’ve added the minLength
validator for the firstName
field. We will write more about available validators and their options.
Validators on the field level
You can also define validators with a field definition, to keep them together and make it more readable. To do that you have to define validator under the validator
property.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: {
type: 'string',
validator: Validators.minLength(3)
}
}
});
NOTICE: In a field definition the correct property name is validator
(singular) and in the class definition it validators
(plural).
Passing array of validators
As a value of the validators
or validator
property you can pass array of validators. In such situation array of validators will be replaced with the and
validator. The and
validator means that all sub-validators has to pass validation test to mark field’s value as valid. The two following examples are equivalent.
The and
validator:
User = Astro.Class({
name: 'User',
/* */
validators: {
firstName: Validators.and([
Validators.required(),
Validators.string()
])
}
});
Array of validators:
User = Astro.Class({
name: 'User',
/* */
validators: {
firstName: [
Validators.required(),
Validators.string()
]
}
});
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.
var reqStrMin3 = Validators.and([
Validators.required(),
Validators.string(),
Validators.minLength(3)
]);
User = Astro.Class({
name: 'User',
/* */
validators: {
firstName: reqStrMin3,
lastName: reqStrMin3
}
});
Types of validator params
Most of the validators take a param as the first argument. The param may differ from validator to validator. Let’s examine some cases.
Array of validators. They and
and or
validators are the only two predefined validators that take an array of validators as a param. We will write more about them in next sections.
Validators.and([
Validators.string(),
Validators.minLength(3)
]);
Validators.or([
Validators.string(),
Validators.minLength(3)
]);
There are validators that take a single plain value (string, number) as a param. The examples of them are: minLength
, equal
, contains
. We will write more about them in next sections.
Validators.minLength(3);
Validators.equal('mustBeEqualToThisString');
Validators.contains('mustContainThisString');
There are validators that take array of some values or object with some validator details. The examples of them are: choice
, if
. We will write more about them in next sections.
Validators.choice(['value', 'has', 'to', 'match', 'one', 'of', 'these']);
Validators.if({
condition: function() {
return this.lastName > 5;
},
true: Validators.maxLength(10),
false: Validators.minLength(2)
});
There are also validators that does not take any param. The example of them are: string
, number
, boolean
.
Function as a validator param
There is a special type of a param. If a validator takes parameter, you can also pass a function as a param. In such situation, the param value will be calculated on validation execution. It’s very useful when we want to depend validation of one field on the value of another field.
validators: {
firstName: Validators.string(),
lastName: Validators.minLength(function() {
// A value of the "lastName" field has to be at least as long as a value of
// the "firstName" field.
return this.firstName.length;
});
}
NOTICE: It’s important to remember that a function param works with every validator that takes param as the first argument.
Errors
There are several ways of generating an error message when validation fails. Of course, you can always use default error messages that comes with the validators module. 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.
A validation message in a field validator
The simplest and less flexible way of generating error message is passing it as the second argument of a validator. Let’s take a look at the example.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: {
type: 'string',
validator: Validators.minLength(3, 'The first name is too short!')
}
}
});
As you can see, we passed the "The first name is too short"
string as the second argument of the minLength
validator. Each validator can have its own error message or it can be left empty, and in such situation a default error message will be used.
Generating an error message in an event
There is the validationError
event that is triggered on a validation error. There are some useful information passed to that event in an event object. They are:
e.data.validator
- the field validator objecte.data.validator.name
- the name of the field validatore.data.fieldValue
- the value of the fielde.data.fieldName
- the name of the fielde.data.param
- the param value passed to the field validatore.data.message
- the current error message
There are also two method on the event object:
e.setMessage()
- sets a new error messagee.getMessage()
- gets the current error message
Now, let’s take a look at the example usage of the validationError
event.
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: {
type: 'string',
validator: Validators.minLength(3)
}
},
events: {
validationError: function(e) {
if (
e.data.validator.name === 'minLength' &&
e.data.fieldName === 'firstName'
) {
// Set a new error message.
e.setMessage('The first name is too short!');
// You have to stop propagation.
e.stopPropagation();
}
}
}
});
As you can see, in the validationError
event handler we check if a validator that caused error is minLength
and if a field name is firstName
. If the condition is met, then we set a new error message using the e.setMessage()
method. We also have to stop propagation of the following events.
As in all document events the event propagation of the validationError
event goes from the parent class, through the child class and gets to the global scope. Knowing that we could also “catch” the validationError
event in the global scope.
Astro.eventManager.on('validationError', function(e) {
if (
e.data.validator.name === 'minLength' &&
e.data.fieldName === 'firstName'
) {
// Set a new error message.
e.setMessage('The first name is too short!');
// You have to stop propagation.
e.stopPropagation();
}
});
If we don’t generate an error message in an event handler then it will be generated in a default event handler for a validator. If a validator does not have an event handler for the validationError
event, then the default validation error will be used.
Accessing error messages
Errors generated on a validation fail can be accessed using a few methods. We can not only get a validation error message but also check if there are any errors and if a given field has a validation error. Let’s examine all available functions.
doc.hasValidationErrors()
- check if there are any validation errorsdoc.hasValidationError(fieldName)
- check if there is a validation error for a given fielddoc.getValidationErrors()
- get all validation errorsdoc.getValidationError(fieldName)
- get a validation error for a given field
All of these methods are reactive.
The getValidationErrors()
method returns an object containing key-value pairs where the key is field name and the value is a validation error message.
Displaying errors in a template
Getting a validation error message is one thing but displaying it in a template another one that needs explanation. All described methods can be used in a template. Let’s take a look at how we can create an input field with a validation error message displayed underneath only when there is any for a given field.
<input id="firstName" type="text" />
<div class="error"></div>
Validation error message for nested fields
It’s important to notice that error messages for nested fields resides inside of the nested fields. So, if we have the User
class that has the nested address
field which stores an instance of the Address
class, then getting error for the address.city
field would look like in the example below.
<input id="city" type="text" value="" />
<div class="error"></div>
As you can see, that time we used the hasValidationError()
method from the address
field and we passed the "city"
string as its argument. The same is true if it goes about the getValidationError()
method.
Clearing error messages
If a field validation failed then an error message resides in the document and is correlated with the given field. Now, if you set a new value for the field then the error message will be cleared for that field to prepare a document for next validation. However you may want to clear all validation errors. You can do it using the clearValidationErrors()
method.
var user = new User();
user.validate();
user.getValidationErrors(); // {firstName: "..."}
user.clearValidationErrors();
user.getValidationErrors(); // {}
Validators
string
Validators.string();
The string
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is a string.
number
Validators.number();
The number
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is a number.
boolean
Validators.boolean();
The boolean
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is a boolean.
array
Validators.array();
The array
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is an array.
object
Validators.object();
The object
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is an object.
date
Validators.date();
The date
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is a date.
required
Validators.required();
The required
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is not an empty value like null
or ""
(empty string).
null
Validators.null();
The null
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is null
.
notNull
Validators.notNull();
The notNull
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is not null
.
length
Validators.length(size);
The length
validator takes a number as the first argument and its function is to check whether the length of a value of a field is exactly size
characters long. Where size
is the first argument of the validator. It can also works with fields of the Array
type. In such situation, it checks number of elements in an array.
minLength
Validators.minLength(size);
The minLength
validator takes a number as the first argument and its function is to check whether the length of a value of a field is at least size
characters long. Where size
is the first argument of the validator. It can also works with fields of the Array
type. In such situation, it checks number of elements in an array.
maxLength
Validators.maxLength(size);
The maxLength
validator takes a number as the first argument and its function is to check whether the length of a value of a field is at most size
characters long. Where size
is the first argument of the validator. It can also works with fields of the Array
type. In such situation, it checks number of elements in an array.
gt
Validators.gt(size);
The gt
validator takes a number as the first argument and its function is to check whether a value of a field is greater than the size
. Where size
is the first argument of the validator. It can also works with fields of the Date
type and other types that are comparable with numbers.
gte
Validators.gte(size);
The gte
validator takes a number as the first argument and its function is to check whether a value of a field is greater than or equal the size
. Where size
is the first argument of the validator. It can also works with fields of the Date
type and other types that are comparable with numbers.
lt
Validators.lt(size);
The lt
validator takes a number as the first argument and its function is to check whether a value of a field is less than the size
. Where size
is the first argument of the validator. It can also works with fields of the Date
type and other types that are comparable with numbers.
lte
Validators.lte(size);
The lte
validator takes a number as the first argument and its function is to check whether a value of a field is less than or equal the size
. Where size
is the first argument of the validator. It can also works with fields of the Date
type and other types that are comparable with numbers.
Validators.email();
The email
validator doesn’t take any options as the first argument and its function is to check whether a value of the field is a string with a valid email address.
// Example:
validators: {
email: Validators.email()
}
choice
Validators.choice(choices);
The choice
validator takes a list of valid values as the first argument and its function is to check whether a value of the field is one of them.
// Example:
validators: {
sex: Validators.choice(['male', 'female'])
}
unique
Validators.unique();
The unique
validator takes no arguments and checks whether the value of the field is unique.
Currently the unique
validator should only be used to validate top level fields. It will not work with nested fields.
// Example:
validators: {
// Each document has to have unique email address.
email: Validators.unique()
}
NOTICE: The unique
validator should be used on the server because on the client we can be subscribed to not entire set of documents and checking uniqueness in such situation may not be reliable.
equal
Validators.equal(comparisonValue);
The equal
validator takes a comparison value as the first argument and its function is to check whether a value of the field is equal to the comparison value.
// Example:
validators: {
captcha: Validators.equal('aBcDeFg')
}
equalTo
Validators.equalTo(fieldName);
The equalTo
validator takes a field name as the first argument and its function is to check whether a value of the field is equal to the value of a field passed as the argument.
// Example:
validators: {
// Check if values of `password1` and `password2` fields are equal.
password1: Validators.equalTo('password2')
}
regexp
Validators.regexp(regularExpression);
The regexp
validator takes a regular expression as the first argument and its function is to check whether a value of the field is matches the regular expression passed as the argument.
// Example:
validators: {
login: Validators.regexp(/^[a-zA-Z0-9]+$/)
}
and
Validators.and(validatorsList);
The and
validator takes a list of validators as the first argument and its function is to check whether a value of the field passes validation of all validators from the list.
validators: {
firstName: Validators.and([
Validators.string(),
Validators.minLength(3)
])
}
or
Validators.or(validatorsList);
The or
validator takes a list of validators as the first argument and its function is to check whether a value of the field passes validation of any validator from the list.
validators: {
// Age has to be between 18 and 30 or between 45 and 60
age: Validators.or([
Validators.and([
Validators.minLength(18),
Validators.maxLength(30)
]),
Validators.and([
Validators.minLength(45),
Validators.maxLength(60)
])
])
}
if
Validators.if({
condition: function(fieldValue, fieldName) {},
true: validator
false: validator /* Optional */
});
The if
validator takes an object with some options as the first argument. The available options are condition
, true
and false
. The false
option is not obligatory. In the condition
function, you have to return true
or false
value which will determine usage of the true
or false
validator accordingly. The condition
function is executed in the context of the given document, so you can base condition on values of other fields in a document. The condition
function also receives two arguments. The first one is a current field value and the second one is a current field name.
// Example:
validators: {
someField: Validators.if({
condition: function(fieldValue, fieldName) {
return this.otherField.length > fieldValue.length
},
true: Validators.and([
Validators.string(),
Validators.email()
])
})
}
switch
Validators.switch({
expression: function(fieldValue, fieldName) {},
cases: {
value1: validator,
value2: validator,
value3: validator
/* ... */
}
});
The switch
validator takes an object with some options as the first argument. The available options are expression
and cases
. Both options are obligatory. In the expression
function, you have to return one of the keys in the cases
object. It will take the returned value and validate a value of a field using proper validator from the cases
object.
// Example:
validators: {
someField: Validators.switch({
expression: function(fieldValue, fieldName) {
return fieldValue.length
},
cases: {
4: Validators.regexp(/^d+$/),
6: Validators.regexp(/^[a-z]+$/)
}
})
}
every
The every
validator takes a validator as the first argument. The validator function is to check whether every element of a field’s value, which should be an array, passes validation using the validator passed as the first argument.
// Example:
Post = Astro.Class({
name: 'Post',
/* ... */
fields: {
tags: {
type: 'array',
nested: 'string',
default: function() {
return [];
},
validator: [
// Up to 100 tags per post.
Validators.maxLength(100),
// Each tag has to...
Validators.every(
Validators.and([
// ... be a string and...
Validators.string(),
// ... at least 3 characters long
Validators.minLength(3)
])
)
]
}
}
});
has
Validators.has(propertyName);
The has
validator takes a property name as the first argument. Its function is to check whether a value of a field, which should be an object, has the property property.
validators: {
address: Validators.has('city')
}
contains
Validators.contains(soughtArrayElement);
The contains
validator takes a sought element as the first argument. Its function is to check whether a value of a field, which should be an array, contains the sought element.
validators: {
tags: Validators.contains('meteor')
}
Creating validators
We will describe a process of creating a validator on the example of the maxLength
validator. Here is the entire code of the validator.
Astro.createValidator({
name: 'maxLength',
validate: function(fieldValue, fieldName, maxLength) {
if (_.isNull(fieldValue) || !_.has(fieldValue, 'length')) {
return false;
}
return fieldValue.length <= maxLength;
},
events: {
validationError: function(e) {
var fieldName = e.data.fieldName;
var maxLength = e.data.param;
e.setMessage(
'The length of the value of the "' + fieldName +
'" field has to be at most ' + maxLength
);
}
}
});
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 validate
function. It should return a boolean value indicating if a value of a given field passes validation. The validate
function receives three arguments: a field’s value, a field name and a param. The param argument can be for instance the number with which we are comparing a field’s value. In the example of the maxLength
validator, the param argument is the maxLength
of the string.
There is also an optional attribute which is the events object with the definition of the validationError
event. The validationError
event receives an event object as the first argument. We should generate an error message on validation fail. To generate an error message just use the e.setMessage()
method.
Validation order
By default validators are executed in the order of their definition, however we can change it providing a new order under the validationOrder
property in the class schema. The validationOrder
property is an array or fields in which validation should take place. Let’s take a look at the example below.
User = Astro.Class({
name: 'User',
/* ... */
validators: {
firstName: Validators.minLength(3),
lastName: Validators.minLength(3),
birthDate: Validators.date()
},
validationOrder: [
'birthDate',
'firstName',
'lastName'
]
});
Now original validation order will be ignored. You can also pass not complete list of validation order. The lacking validators will be added in the order of their definition.
User = Astro.Class({
name: 'User',
/* ... */
validators: {
firstName: Validators.minLength(3),
lastName: Validators.minLength(3),
birthDate: Validators.date()
},
validationOrder: [
'birthDate'
]
});
In such situation validation order will be: birthDate
, firstName
, lastName
.
Simple validators
The jagi:astronomy-simple-validators
package is an extension of the core validation package jagi:astronomy-validators
. The ‘jagi:astronomy-validators’ package uses functional validators which are fast and powerful. However, they require a little bit more code to be written. There are situations where you can sacrifice all the benefits of functional validators for more concise string validators that come with the jagi:astronomy-simple-validators
package.
To use the simple validators package you don’t have to add the core jagi:astronomy-validators
package. It’s a dependency for the simple validators package and it will be added automatically.
meteor add jagi:astronomy-simple-validators
Adding simple validators
We can add simple validators on the level of class or on the level of a field definition. We have here the same rule, as with normal validators, if it goes about the property name for defining validators. If we’re defining simple validators on the level of a class we use a plural form simpleValidators
and when we are adding validator on the level of a field definition then we use a singular form simpleValidator
. Let’s see both definitions.
The class level:
// .
User = Astro.Class({
name: 'User',
/* ... */
simpleValidators: {
firstName: 'minLength(3)'
}
});
The field level:
User = Astro.Class({
name: 'User',
/* ... */
fields: {
firstName: {
type: 'string',
simpleValidator: 'minLength(3)'
}
}
});
As you can see, we’ve added the minLength
validator to the firstName
field. The validation rules have to be written in the form of a string. We just write a validator name as it would be a function and pass a parameter in the parentheses. The minLength
validator is one of many predefined validation functions. Almost all validators from jagi:astronomy-validators
package can be used in the jagi:astronomy-simple-validators
package. There are some limitations where we can’t use objects as a validator param. In such situation, you have to use functional validators.
Validation error message
There is also a way of passing a custom error message to the validator.
User = Astro.Class({
name: 'User',
/* ... */
simpleValidators: {
firstName: {
rules: 'minLength(5)',
messages: {
minLength: 'The first name is too short!'
}
}
}
});
As you can see, instead passing a string rules, we pass object with rules
and messages
properties. A value of the messages
property is an object of key-value pairs, where the key is a validator name and the value is an error message for the given validator.
Complex validation rules
For now, we’ve shown how to add a single string validator per field, but what about multiple validation rules. We can create more complex validation rules. One possible way is the and
validator which is created when we separate validators with the comma sign.
simpleValidators: {
firstName: 'required,string,minLength(3)'
}
Relations
The relations module provides ability to define relations between classes assigned to different collections. You can add this module to your Meteor project using the following command.
meteor add jagi:astronomy-relations
Adding relations
Let’s say we have two classes, User
and Address
, and we want to create a “one-to-many” relation. It would mean that one user can have many addresses associated with them. Take a look at the definition of the Address
class:
Addresses = new Mongo.Collection('addresses');
Address = Astro.Class({
name: 'Address',
collection: Addresses,
fields: {
city: 'string',
state: 'string',
street: 'string',
userId: 'string'
}
});
And now the User
class with the defined relation:
Users = new Mongo.Collection('users');
User = Astro.Class({
name: 'User',
collection: Users,
fields: {
firstName: 'string',
lastName: 'string'
},
relations: {
addresses: {
type: 'many',
class: 'Address',
local: '_id',
foreign: 'userId'
}
}
});
As you can see, we defined the addresses
relation which points to the Address
class.
We also have here two extra attributes, local
and foreign
. The local
attribute says that any Address
is related with the User
via the User
’s _id
attribute. The foreign
key specifies that the value of the _id
attribute will be stored in the userId
field of instances of the Address
document.
Getting related documents
Having relation defined, it’s possible to execute the following code to get all addresses associated with a given user.
var user = Users.findOne();
user.addresses().forEach(function (address) {
/* Do something with the address */
});
As you can see there is the addresses
method added by the relation which we can call and receive a Mongo cursor pointing to all addresses of a given user.
Troubleshooting
There are several warnings that can be printed in the console that you may think are unnecessary. Nothing like that, there is always a reason behind any warning. You’re probably doing something wrong.
Probably the most common warning is Trying to set a value of the "field" field that does not exist in the "Class" class'
. The warning is caused by trying to set a field that is not defined in the class schema. You were probably playing with a class schema and inserted some values into database that you are not using anymore. In that situation you should clean your collection from unnecessary fields.
If you are right that everything is correct and you still receives warnings, then you can turn off them at all.
Astro.config.verbose = false;
Behaviors
Timestamp
You can add the timestamp
behavior to your project by executing the following command.
meteor add jagi:astronomy-timestamp-behavior
The timestamp
behavior adds two fields that store information about document creation and update dates.
The timestamp
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
You can add the slug
behavior to your project by executing the following command.
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 slug
behavior comes with following options.
behaviors: {
slug: {
// The field name from which a slug will be created.
fieldName: 'title',
// The method name that generates a value for the slug-ification process.
methodName: 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 and methodName
There are two possible ways of generating a slug: from the value of a field or from the value returned by a method.
If we want to generate a slug from a single field then we have to provide a field name in the fieldName
option.
If we want to generate a slug from multiple fields or from a manually generated value then we have to provide a method name in the methodName
option.
NOTICE: You can’t use both ways of generating slug. You have to choose between the fieldName
option or the methodName
option.
Let’s take a look at the example of generating a slug using a method.
User = Astro.Class({
/* ... */
methods: {
fullName: function() {
// Slug will be generated from the returned value.
return this.firstName + ' ' + this.lastName;
}
},
behaviors: {
slug: {
// You have to set null here if you want to use "methodName" option
fieldName: null,
// The method name that generates a value for the slug-ification process.
methodName: 'fullName'
}
}
});
The generateSlug() method
You can also use the generateSlug()
method to manually generate a slug in any moment no only on a document save. Let’s take a look at the example usage.
User = Astro.Class({
/* ... */
methods: {
fullName: function() {
return this.firstName + ' ' + this.lastName;
}
},
events: {
afterInit: function() {
// We can generate a slug after initialization of a document.
this.generateSlug();
},
afterSet: function(e) {
var fieldName = e.data.fieldName;
if (fieldName === 'firstName' || fieldName === 'lastName') {
// We can also generate a slug when one of the fields that creates a
// slug has changed.
this.generateSlug();
}
}
},
behaviors: {
slug: {
fieldName: null,
methodName: 'fullName'
}
}
});
Softremove
You can add the softremove
behavior to your project by executing the following command.
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 softremove
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 = Users.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 = Users.find();
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.
Sort
You can add the sort
behavior to your project by executing the following command.
meteor add jagi:astronomy-sort-behavior
The sort
behavior introduces documents sorting. You can have one or more lists per collection. You can move documents up and down, take them out of the list or insert a new document into the list at a desired position.
The sort
behavior comes with following options.
behaviors: {
sort: {
// The field name that stores position of a document.
orderFieldName: 'sort',
// A flag indicating possibility to store multiple lists per collection.
hasRootField: false,
// The field name for storing a value distinguish to which list a given
// document belongs.
rootFieldName: 'root'
}
}
Let’s take a look at the behavior usage.
var user = Users.findOne();
user.insertAt(0);
We inserted a document at the first position of a list. Any document that is already on the list will be moved up (a value of the order
field will be incremented).
To take a document out of the list (remove it), we use the takeOut()
method.
user.takeOut();
NOTICE: When using the sort behavior, you can’t use save()
and remove()
methods to insert or removed a document. Any operation related with changing the position of a document on the list has to be done using behavior’s methods. You can use the save()
to update any other value of the document.
Let’s see what methods the sort
behavior provides.
insertAt(position)
- inserts a document at a given positiontakeOut()
- removes a document from the listmoveBy(shift)
- moves a document up/down by a given amountmoveTo(position)
- moves a document to the given positionmoveUp()
- moves a document up by 1moveDown()
- moves a document down by 1moveToTop()
- moves a document to the top of the listmoveToBottom()
- moves a document to the bottom of the list
Advanced usage
Custom types
You can create custom types by using the Astro.createType()
method. You have to pass a type definition object as the first argument of the function. The only required property is a type name
. However, in most cases you would only have to provide cast
and plain
methods. Let’s take a look at possible properties that you can provide in a type definition.
Astro.createType({
name: 'type',
constructor: function Type(fieldDefinition) {},
getDefault: function(defaultValue) {},
cast: function(value) {},
needsCast: function(value) {},
plain: function(value) {},
needsPlain: function(value) {}
});
Now, we will investigate each property:
constructor
- the constructor function is the one that receives a field definition as the first argument. We can get some extra data from this definition and initialize a field. For example in theobject
type, we get thenested
property and initialize a sub type.getDefault
- its function is to make sure that a default value of a field will be casted to the proper type defined for a field. A default value for a field is passed as the first argument of a method. If you don’t providegetDefault
method, then a default value will be casted anyway. This method is used inobject
andarray
types.cast
- it receives as the first argument a value being casted. Your task is to cast a given value to your type and return it.needsCast
- it’s a helper function that can speed up a process of casting values. It just checks if there is a need for running thecast
function. If a passed value is already an instance of a given type then we can just returntrue
in theneedsCast
method.plain
- its job is to convert a value of your type to a plain JavaScript value. This plain value will be stored in the database.needsPlain
- it’s a helper function that is very similar to theneedsCast
method. It just makes sure that there is a need for running theplain
method.
Let’s take a look at the example type.
Astro.createType({
name: 'date',
constructor: function DateField() {
Astro.BaseField.apply(this, arguments);
},
needsCast: function(value) {
return !_.isDate(value);
},
cast: function(value) {
return new Date(value);
},
plain: function(value) {
return value;
}
});
In the constructor
function, we call the Astro.BaseField
constructor in the context of our type. It always has to be done.
The needsCast
function is just checking if a value being cast is already a date.
The cast
function uses the Date
constructor to parse a value.
The plain
function just returns value. Date is not a plain JavaScript type, but Mongo is able to store dates, so we don’t have to get timestamp for a date in the plain
method.
The best way to learn how to create a custom type is checking how already defined types were defined.
Writing behaviors
When you notice that you’re repeating some parts of a code over and over again for some classes it may be a good idea to create a behavior. Behavior is a module that provides some functionality for a class. It may be a feature that adds fields for storing document creation and modification dates that are automatically updated on a document insert and update. It may be a feature that adds the slug field that stores an URL friendly form of a document title. There are some behaviors that have been already implemented and you can add them to the class. In this section we will write about creating custom behaviors using the timestamp
behavior as an example.
To create a behavior you have to use the Astro.createBehavior()
function. As the first and only argument you pass a behavior definition. It’s an object with key-value pairs. Here is a list of required properties.
name
- name of a behavioroptions
- an object with behavior options (if any) and default valuescreateSchemaDefinition
- a function that should return a schema definition that would be used in theClass.extend()
method.
We will discuss each property in detail.
Behavior name
The behavior name is used in a class definition to determine which behavior we want to add to our class.
Astro.Class({
behavior: ['behaviorName']
});
// Or
Astro.Class({
behavior: {
behaviorName: {}
}
});
Behavior options
Some behaviors can have ability to customize them. You can for example change name of the field being added to the class. In the timestamp
behavior you can for example decide what will be the names of fields for storing creation and modification dates.
Astro.createBehavior({
name: 'timestamp',
options: {
hasCreatedField: true,
createdFieldName: 'createdAt',
hasUpdatedField: true,
updatedFieldName: 'updatedAt'
},
/* ... */
});
Each option should have a default value. The developer shouldn’t have to define any option value when adding a behavior.
The createSchemaDefinition function
The createSchemaDefinition()
function is a heart of behavior. It should return a class definition that can be used in the extend()
method of a class.
User = Astro.Class({/* ... */});
User.extend(schemaReturnedFromBehavior);
The class extension is done automatically by Astronomy when a behavior is being added to the class. However, you should know what form it has to have. Now, let’s take a look at how the timestamp
behavior is constructing this schema definition.
Astro.createBehavior({
/* ... */
createSchemaDefinition: function(options) {
var schemaDefinition = {
fields: {},
events: events
};
if (options.hasCreatedField) {
// Add a field for storing a creation date.
schemaDefinition.fields[options.createdFieldName] = {
type: 'date',
immutable: true,
default: null
};
}
if (options.hasUpdatedField) {
// Add a field for storing an update date.
schemaDefinition.fields[options.updatedFieldName] = {
type: 'date',
optional: true,
default: null
};
}
return schemaDefinition;
}
});
As you can see, in the first line, we create a new schema definition object that is returned in the return statement on the last line. The actual body of the function fills this schema with all data needed for the behavior to work.
It’s important to notice, that the createSchemaDefinition()
function receives an options
object as the first argument. This options
argument is filled with default values of options overridden with options defined by a developer while adding a behavior to the class.
Astro.Class({
behavior: {
behaviorName: {
// Override a default value of this option.
createdFieldName: 'creationDate'
}
}
});
We use the options
argument to check if some flags were set options.hasCreatedField
and add a field with a name defined in options options.createdFieldName
.
Events
As you may notice, there is also the events
object that is a list of event that will be added to the class. For the timestamp
behavior we add the beforeInsert
and beforeUpdate
events. We will examine only the beforeInsert
event.
events.beforeInsert = function() {
var doc = this;
var Class = doc.constructor;
// Find a class on which the behavior had been set.
var classBehavior = Class.getBehavior('timestamp');
var options = classBehavior.options;
// Get current date.
var date = new Date();
// If the "hasCreatedField" option is set.
if (options.hasCreatedField) {
// Set value for created field.
this.set(options.createdFieldName, date);
}
if (options.hasUpdatedField) {
// Set value for the "updatedAt" field.
this.set(options.updatedFieldName, date);
}
};
In the beforeInsert
event, we have to know what are the values of options for a given behavior. Maybe someone didn’t want to have a field for storing the creation date. We can get options for a behavior by executing the Class.getBehavior(behaviorName)
method. The this
context in an event is a document on which an event was triggered, so the line var doc = this;
. When can get the class for a document getting its constructor var Class = doc.constructor;
. Now having a class function, we can get behavior’s options.
var classBehavior = Class.getBehavior('timestamp');
var options = classBehavior.options;
Writing modules
Astronomy is highly modularized and developer can hook into almost any process. Behaviors are class specific. However, modules can introduce some global objects can influence the process of building class from a schema etc. In this section, I will describe the most important features. However, it’s best to investigate code of already existing modules to better understand how to create your own module.
The initDefinition event
The first thing that your module will probably mess with is a class schema. There is the global initDefinition
event that can modify schema to fit your needs.
Astro.eventManager.on('initDefinition', function(schemaDefinition) {
// Modify the "schemaDefinition".
});
In the internal fields
module, this event is responsible for parsing fields list. As you may know you can provide array of fields names or an object with fields names and fields definitions. In the initDefinition
event the array of fields are converted to objects, so it can be later processed by a module without the need to distinguish if fields definition is array or object.
The initSchema event
The initSchema
event gets a schema definition and applies it to the schema of a class. Each class has an internal schema
object that stores some module specific data that makes it easier and faster to operate.
User = Astro.Class({ /* ... */ });
User.schema; // Schema object.
The initSchema
event receives the schemaDefinition
object as the first argument of an event handler. Let’s take a look at the example.
Astro.eventManager.on('initSchema', function(schemaDefinition) {
var schema = this; // The "this" context is the current schema object.
// Add the "validators" attribute to the schema.
schema.validators = schema.validators || {};
if (_.has(schemaDefinition, 'validators')) {
// Check if there are any validators in the schema and convert them to
// field validators.
}
}
);
The initClass event
The initClass
event is responsible for adding some methods or properties to the class constructor.
User = Astro.Class({ /* ... */ });
User.getFieldsNames(); // Method added in the "fields" module.
As you can see in the example below, the this
context in an event handler is a class constructor. We can add some methods and properties to the given class.
Astro.eventManager.on('initClass', function() {
var Class = this;
Class.getFieldsNames = function() {
return /* ... */;
};
});
Methods in instances of all clasess
If you want to add some method or properties to intances all classes you should extend the Astro.BaseClass
prototype. All Astronomy classes inherits its prototype.
Astro.BaseClass.prototype.methodForAllClasses = function() {
/* ... */
};
var user = new User();
user.methodForAllClasses();