• If you are citizen of an European Union member nation, you may not use this service unless you are at least 16 years old.

  • You already know Dokkio is an AI-powered assistant to organize & manage your digital files & messages. Very soon, Dokkio will support Outlook as well as One Drive. Check it out today!

View
 

DataStore-Defining Your Model

Page history last edited by geoffreyd 13 years, 5 months ago

Usually the first thing you need to do when using the DataStore framework is to define your model records.  Model records tell SproutCore how to map the JSON data hashes you receive from your server to high-level properties and relationships that will be used by the rest of your application.

 

For example, your server may send you a list of accounts with a list of usernames belonging to those accounts along with other information such as the date the account was opened and its current plan.  All of these properties may be simply expressed in your data hash as strings.  The model you define in your application will enable the DataStore framework to automatically convert these strings to User objects, Date objects, and a Plan object respectively.

 

Designing Your Schema

 

Before you start writing any code, usually the best place to start designing your model is on paper (or in OmniGraffle, if you prefer).  It is usually easier for you and any team you work with if you first diagram the basic data types you expect to work with and how they relate to each other.

 

Usually you can design your model by putting together a basic schema diagram just like you would with a database.  For each type of record you plan to work with, identify the attributes the record will have and any relationships between the records. 

 

Attributes can be any simple or complex type you want.  Simple types like Strings, Numbers, and Booleans, are supported natively by the framework.  More complex types, such as points or rects, may require additional code.

Relationships are associations between different records in your data model.  In general, there are three basic relationships types you may have between records:

 

  1. One-to-one.  This means one record may relate to another single record.  For example, a User may have one Email.  An Email may belong to only one User.
  2. One-to-Many.  This means one record may relate to one or more other records in some way.  For example, an Account may have many Users, while a User may belong to only one Account.
  3. Many-to-Many.  This means one record may related to one or more other records in some way while the other records may relate to one or more back.  For example, a UserGroup may have zero or more Users while a User may belong to zero or more UserGroups.  

 

There are many good resources online that will tell you everything you need to know about designing a schema.  In general they all apply equally well to designing the API for your DataStore.  In fact, often times if you have a schema already designed for a server-side database, you will be able to use the same schema as the basis for your SproutCore app.

 

The only difference between DataStore schemas and database schemas is that the DataStore does not require the use of a join table to represent a many-to-many relationship.

 

The diagram below shows an example schema diagram for a User management system on a SaaS application.  This schema will be used throughout the rest of the book as we implement various pieces of it.

 

 

Creating a Basic Model

 

Every model object in the DataStore framework is defined as a subclass of SC.Record.  The simplest way to define a model object is to simply define the class like so:

 

MyApp.Account = SC.Record.extend({

});

 

That’s all there is to it.  You can now create new Account records in your store, modify them, and commit them back.  Since SC.Record objects wrap a data hash, by default any property you read or write will pass through to the data hash.  In the example above, you could create an Account and give it a name with the following code:

 

var account = MyApp.store.createRecord(MyApp.Account, 1);

account.set(‘name’, ‘Account Name’);

 

The call to createRecord() generates a new data hash, a new Account record to wrap it and then returns the Account record.  The second parameter you pass is the record ID.  This ID must be unique for all Accounts.  Although providing a GUID is not enforced by the framework, failing to do so robs the store of a way to uniquely identify records, which may cause other problems for you down the road.

 

The code above would yield a data hash with the name set, a record ID, and no other properties:

 

account.get(‘attributes’)

>> { “name”: “Account Name”, guid: 1 }

 

Record Ids

 

Every record in your store must have an ID.  The ID must be unique among other records of the same type.  It need not be unique among all records.  By default, the DataStore framework looks for a property named “guid” on your data hash to determine the ID.  You can change the name of the property used for your ID by setting the primaryKey property on your SC.Record. 

 

For example, the code below will make the key used for the record ID “foo”:

 

MyApp.Account = SC.Record.extend({

  primaryKey: “foo”

});

 

No matter what you set the primaryKey to, you can always get the current ID of a record using the “id” property.  For example, if you used the record definition above then create a record like the following:

 

var rec = MyApp.store.createRecord(MyApp.Account, {  "foo": "bar" });

rec.get('id');

>> "bar"

 

Likewise, setting the “id” property of a record, will change its ID in the store.  This is fairly dangerous and can cause inconsistencies in your data model, so it is not recommended.

 

 

Record Attributes

 

Of course, a record that simply allows properties you read and write to pass through it has limited value.  What if you tried to set the “name” property to a number?  That would could yield invalid JSON yet nothing here would catch it.

 

To provide more detailed control over properties, usually you will want to use attribute helpers on your SC.Record.  Attribute helpers are special objects (subclassed from SC.RecordAttribute) that act as computed properties on your record.  When you set()/get() an attribute on your record, the attribute helper will automatically convert the type to the underlying data hash value.

 

You define attribute helpers as properties on your SC.Record classs using the SC.Record.attr() helper method.  It looks like this:

 

MyApp.Account = SC.Record.extend({

  name: SC.Record.attr(String)

});

 

Now if you try to set the value of “name” to anything other than a String, it will be converted to a string:

 

var account = MyApp.store.createRecord(MyApp.Account, {name:'John'}, 1);

account.set(“name”, 1234);

account.get(‘attributes’);

>> { “name”: “1234”, “guid”: 1 }

 

You should use attribute helpers to define all of the attributes you expect to find in your data hash.  There are built in coercion helpers for all the primitive data types including Strings, Numbers, Booleans, and Dates.  

 

Adding Default Values

 

In addition to performing basic type coercion,  attribute helpers can also provide other functions, including filling in default values if one has not been supplied yet.  This is useful for example if you expect to sometimes get data from the server with properties missing and you want to make sure something is always provided.

 

You pass this option (as well as any other SC.RecordAttribute options) as a second parameter to the SC.Record.attr() helper.  The following example will assume isActive is true unless some other value is provided.

 

MyApp.Account = SC.Record.extend({

  name: SC.Record.attr(String),

  isActive: SC.Record.attr(Boolean, { defaultValue: YES })

});

 

Renaming Keys 

 

Usually you will define attributes on your record that match exactly the properties you expect to find in the data you get from the server.  Sometimes, however, this won't work directly.  For example, the "status" property is reserved on SC.Record objects.  If you name an attribute on your data hash "status", simply defining the same attribute name on your record would break the SC.Record:

 

// this would break SC.Record!  - status is reserved

MyApp.Account = SC.Record.extend({

 status: SC.Record.attr(Number)

});

 

In these cases, you can actually name an attribute anything you want on your record and map it to the "status" key on your data hash.  You configure this in your options hash for the attr() helper.  For example, here is how you could rename "status" to "accountStatus":

 

MyApp.Account = SC.Record.extend({

   accountStatus: SC.Record.attr(Number, { key: "status" })

});

 

WIth this configuration, anytime you get("accountStatus") on your record, SC.Record will actually lookup the "status" key on your data hash.  Likewise setting "accountStatus" on your record will actually modify the "status" key on your data hash.

 

This behavior is also useful if you get key names from the server that don't make as much sense in JavaScript.  For example, let's say your data hash contains an image URL called "image_url".  It would be much nicer if this snake-case named were camelCased instead to make it more JavaScript like.  Here's how you would do this in your attribute helper:

 

MyApp.Account = SC.Record.extend({

  imageUrl: SC.Record.attr(String, { key: "image_url" })

});

 

Reserved Property Names

 

In general the SC.Record API has been kept very simple so you can fill it in with any property names you like.  There are, however, at least four properties that are reserved by the DataStore.  Never define attribute helpers on these names.  If you have a property with one of these names in your data hash from the server, use the Renaming Keys method described above to work around it.

 

The reserved properties are:

 

  • store - points to the store that owns the record
  • storeKey - an internal reference used to find the record data in the store
  • status - the current record status.  changes when a record is busy loading, dirty, etc.
  • attributes - the underlying data hash behind the record

 

Defining Relationships

 

One of the more important aspects of your model is defining the relationships between records in your store.  Given a user; how do you retrieve the account he belongs too?  Or how do you know whether she is part of the “admin” user group or not.

 

The DataStore framework comes with some special attribute helpers that can be used to define relationships just like you can define primitive values.  SC.Record.toOne() maps an attribute to a single record.  SC.Record.toMany() maps an attribute to many records.  You can define one-to-one, one-to-many, and many-to-many relationships by using combinations of these helpers on two models.

 

Take, for example, mapping users to accounts.  In the schema we defined at the beginning of this chapter, we diagrammed a one-to-many relationship for Accounts and Users.  This means that each Account has many Users while each User belongs to a single Account.  Here’s how you would model this in your code:

 

MyApp.User = SC.Record.extend({

  account: SC.Record.toOne(“MyApp.Account”, { 

    inverse: “users”, isMaster: NO 

  })

  // other attributes...

});

 

MyApp.Account = SC.Record.extend({

  users: SC.Record.toMany(“MyApp.User”, { 

    inverse: “account”, isMaster: YES 

  }),

  // other attributes...

});

 

Note that the first parameter is the name of the record type for the opposite side of the relationships as a string.  This is important because it allows you to express reciprocal relationships like this even if one of the classes has not been defined yet..

 

The “inverse” property on these two objects tells the attribute helper which property on the other record represents the opposite side of the relationship.  When one side is modified, the attribute helper will automatically modify the other attribute to match as well.  For example, setting the “account” property to null on a user account, will automatically remove it from the “users” array on the Account object.

 

The “isMaster” property tells the attribute helpers which record is the “official record” of this relationship.  To one-to-many and one-to-one relationships, this property is important because it controls which records are marked as dirty when the relationship changes.  In the example above, changing either the User#account property or the Account#users property will mark the Account object as dirty; needing to be committed back to the server.  

 

If you do not designate an isMaster property, then both records will be marked dirty when you modify them. 

 

Adding Computed Properties

 

One of the great benefits of using SC.Records to work with your data is that you can synthesize additional properties beyond those that are included with the data hash itself.  For example, let's say you have a Contact data hash that comes with a firstName and lastName.  You will want to often show the contact's full name however.  How would you handle it?

 

One option would be to implement logic all over your application to observe both the firstName and lastName properties whenever they change and then update a displayed full name.  This would involve a lot of duplicate code however.  It's also a good place for bugs to slip in.

 

Instead, you should write a computed property on your SC.Record.  Computed properties are a basic functionality provided by SproutCore for all SC.Objects.  Put simply a computed property is a function that is run whenever you try to get() or set() the property name.  It's a bit like an accessor in other frameworks.

 

In addition to acting as functions, computed properties also include the ability to cache their results and automatically update whenever related properties on the same object are changed.  This can dramatically simplify and speed up your application as you never have to worry about keeping track of property changes.

 

SC.Record objects keep track of when the various data hash attributes you model have changed and notify on those changes just like any other object.  This means you can write computed properties that depend on attributes in your data hash.  Your property will update whenever the underlying data hash changes for any reason.

 

Here's how you could implement the fullName computed property:

 

MyApp.Contact = SC.Record.extend({

  // actual properties found in the data hash

  firstName: SC.Record.attr(String),

  lastName: SC.Record.attr(String),

 

  // computed property.  recalculates when firstName or lastName changes

  fullName: function() {

    return this.getEach('firstName', 'lastName').compact().join(' ');

  }.property('firstName', 'lastName').cacheable()

});

 

As simple as they appear, computed properties are actually one of the most important features for the data store.  Since computed properties can cache their results; and since they avoid recalculating their results until necessary, you can create as many computed properties as you like on your records without much performance penalty.  The more you can rely on these properties to massage data you receive from your server into data you want to display in your UI, the better your application will perform. 

 

Reading and Writing Attributes Directly

 

Sometimes you may need to actually define a computed property that does something custom with the underlying data hash.  For example, maybe you receive some data in a non-normalized form and you want to normalize it before returning.  In this case you can use a computed property and then read and write data hash properties (called attributes) directly on the record. 

 

For example, let's say you are working with a legacy backend that returns an account username either as a simple username or as an email address.  If you get a simple username, you want to actually add a default domain name to make an address.  The built-in record attribute helpers could do something custom like this for you; but a computed property could do the trick:

 

MyApp.Account = SC.Record.extend({

 

  DEFAULT_DOMAIN: '@example.com',

 

  username: function(key, value) {

    // writing ...

    if (value !== undefined) {

      if  (value && value.match(this.DEFAULT_DOMAIN)) {

        value = value.slice(0,-(this.DEFAULT_DOMAIN.length));

      }

      this.writeAttribute('username', value);  // write into data hash

    }

  

    // reading

    value = this.readAttribute('username');

    if (value && !value.match('@')) value = value + this.DEFAULT_DOMAIN;

 

    return value;

  }.property().cacheable() 

});

 

The above example uses readAttribute() and writeAttribute() to actually access the underlying data hash.  It uses cacheable() to avoid recomputing.  You don't need to make these kinds of properties dependent on anything in particular since they will be invalidated automatically when the data hash changes.

 

Whenever you use an attribute helper, it effectively generates a computed property much like this one.  (Though the actual computed property is a RecordAttribute instance instead of a Function.)

 

Adding Transient Properties

 

Sometimes you may want a record to maintain some temporary state that will not be serialized to the server.  These properties (called "transient" properties) are actually very easy to do in SproutCore.  You just define them on your SC.Record.  

 

Because of the way SC.Record works, get() and set() only passes through to the data hash when you define an attribute helper or when the property is undefined on the record itself.  This means defining a property on the record class itself, even if you just set it to null, will actually prevent SC.Record from working on the data hash.  

 

For example, maybe you want to add a property to keep track of the last time a particular contact was viewed in the UI for this run.  Call it viewDate.  This property is transient and normally should remain null until the first time the record is viewed.  You don't want this property to write through to the underlying data hash so you can just define it on your SC.Record:

 

MyApp.Contact = SC.Record.extend({

  firstName: SC.Record.attr('firstName'),

  lastName: SC.Record.attr('lastName'),

 

  // defining will prevent the property from passing through

  viewDate: null 

}); 

 

Putting It All Together

 

This chapter has explained the basic concepts behind defining your record model.  Now let’s put it all together the define the actual model we put together in the schema above.  

 

Although you can define your SC.Record objects anywhere, SproutCore comes with a built-in generator that will create model templates for you.  All you need to do is to name your model.  So let’s start by on the terminal to run sc-gen.  

 

Open your terminal and change to your application directory.  Use the following commands to generate your models:

 

sc-gen model MyApp.Account

sc-gen model MyApp.User

sc-gen model MyApp.UserGroup

sc-gen model MyApp.Plan

 

The generator will create files for each record type in a new “models” directory in your app.  It will also add default test files under “tests/models” and default fixture files under “fixtures”.  The fixture files are especially important as they will allow you to define some default data to start working with even before the server is up and running.

 

We’ll focus on fixtures more in the next chapter.  For now, let’s fill in these model objects.  The simplest of these model’s is the Plan object.  Open up the file at apps/my_app/models/plan.js and fill it in like so:

 

MyApp.Plan = SC.Record.extend({

  name: SC.Record.attr(String),

  maxUsers: SC.Record.attr(Number)

});

 

Next, let’s fill in the User object.  This will need to relate to the UserGroups as well as Account.  It also needs a computed property to tell us if the user is admin.  Open the file at apps/my_app/models/user.js:

 

MyApp.User = SC.Record.extend({

  username: SC.Record.attr(String),

  contactEmail: SC.Record.attr(String),

 

  account: SC.Record.toOne(“MyApp.Account”, {

    inverse: “users”, isMaster: NO 

  }),

 

  groups: SC.Record.toMany(“MyApp.UserGroup”, {

    inverse: “users”, isMaster: NO

  }),

 

  isAdmin: function() {

    return !!this.get(‘groups’).findProperty(‘name’, ‘admin’);

  }.property(‘groups’).cacheable()

 

});

 

Next, the UserGroup property needs to relate to users.  It also belongs to an Account:

 

MyApp.UserGroup = SC.Record.extend({

  name: SC.Record.attr(String),

  users: SC.Record.toMany(“MyApp.User”, {

    inverse: “groups”, isMaster: YES

  }),

 

  account: SC.Record.toOne(“MyApp.Account”, {

    inverse: “groups”, isMaster: NO

  })

});

 

Finally, the Account type relates to everything else:

 

MyApp.Account = SC.Record.extend({

  name:     SC.Record.attr(String),

  isActive: SC.Record.attr(Boolean, { defaultValue: YES }),

  created:  SC.Record.attr(Date),

  plan: SC.Record.toOne(“MyApp.Plan”),

 

  users: SC.Record.toMany(“MyApp.User”, {

    inverse: “account”, isMaster: YES

  }),

 

  groups: SC.Record.toMany(“MyApp.UserGroup”, {

    inverse: “account”, isMaster: YES

  })

});

 

 Congratulations; you’ve defined your basic model.  Now you are ready to start loading data and working with it. 

 

Moving On 

 

Continue to Using Fixtures »

Return to the DataStore Programming Guide Home »

Comments (5)

Dave said

at 7:03 am on Apr 29, 2010

Edited out the line about createRecord adding a GUID for you. That was apparently a fib. (see: http://groups.google.com/group/sproutcore/browse_thread/thread/af45cd5d964d4b91)

Richard Klancer said

at 7:29 am on Apr 29, 2010

Thanks Dave. I was sure I'd heard that createRecord would automatically create a new ID for you.

Richard Klancer said

at 8:24 am on Apr 29, 2010

Two helpful tidbits, regarding one-to-many relationships:

1. if you're using fixtures, you need to explicitly the guid of the 'one' side of the relationship in the fixtures for your 'many' objects, AND you need to specify an array of the guids of the 'many' ids in the fixture for the 'one' side of the relationship.

2. But if you're using createRecord() a new record on the 'many' side, either of the following appear to work.

a. add the new 'many' object to the inverse property on the 'one' side using pushObject.
OR b.

BUT it appears that passing the 'one' side of the many-to-one relationship in the attribute hash passed to createRecord() for the new 'many' record does NOT work (even though it looks so much like the correct method for specifying the same relationship in a fixture):

// this does work

var ownerRecord = MyApp.store.find(MyApp.Owner, 'owner-1');
var newMany = MyApp.store.createRecord(MyApp.ManyObject, {guid: guids++});
newMany.set('owner', ownerRecord);

// this does NOT doesn't set up the inverse relationship!

var newMany2 = MyApp.store.createRecord(MyApp.ManyObject, {guid: guids++, owner: 'owner-1'});

// this doesn't set up the inverse relationship either!
var newMany3 = MyApp.store.createRecord(MyApp.ManyObject, {guid: guids++, owner: ownerRecord});

(Please, someone correct me if I have overgeneralized from my own experiments, or am otherwise mistaken!)

Richard Klancer said

at 8:27 am on Apr 29, 2010

Whoops, the second method (2b above) was supposed to be:

'use set() to set the inverse property (i.e., the one side) to the record from the 'one' side of the relationship'

(i.e., what you see under the 'this does work' comment)

Richard Klancer said

at 8:29 am on Apr 29, 2010

For completeness, this also works:

ownerRecord.get('ownedObjects').pushObject(newMany)

You don't have permission to comment on this page.