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!

rsignavong

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.

banglashi

I still don’t understand how this package is not getting more popular imho this package is better than simple-schema.

dstollie

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!

Kuba Wyrobek

Amazing package indeed! Coming from a php MVC background, this package is a gift :). You rock!

roelvan

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!

dovrosenberg

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:

  1. Parent class event
  2. Child class event
  3. 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 modified
  • e.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 modified
  • e.data.setValue - a field value being set

The beforeInc and afterInc events in the inc() method:

  • e.data.fieldName - a field name being modified
  • e.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 modified
  • e.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 modified
  • e.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 error
  • validate(false) - validate all fields and do not stop after the first error
  • validate(fieldName) - validate a single field
  • validate(arrayOfFieldsNames) - validate multiple fields and stop after the first error
  • validate(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 object
  • e.data.validator.name - the name of the field validator
  • e.data.fieldValue - the value of the field
  • e.data.fieldName - the name of the field
  • e.data.param - the param value passed to the field validator
  • e.data.message - the current error message

There are also two method on the event object:

  • e.setMessage() - sets a new error message
  • e.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 errors
  • doc.hasValidationError(fieldName) - check if there is a validation error for a given field
  • doc.getValidationErrors() - get all validation errors
  • doc.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.

email

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 position
  • takeOut() - removes a document from the list
  • moveBy(shift) - moves a document up/down by a given amount
  • moveTo(position) - moves a document to the given position
  • moveUp() - moves a document up by 1
  • moveDown() - moves a document down by 1
  • moveToTop() - moves a document to the top of the list
  • moveToBottom() - 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 the object type, we get the nested 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 provide getDefault method, then a default value will be casted anyway. This method is used in object and array 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 the cast function. If a passed value is already an instance of a given type then we can just return true in the needsCast 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 the needsCast method. It just makes sure that there is a need for running the plain 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 behavior
  • options - an object with behavior options (if any) and default values
  • createSchemaDefinition - a function that should return a schema definition that would be used in the Class.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();