When you building complex data layer, sooner or later you will need to cross-reference objects by id. Lets consider users and roles as an example. For the purpose of the example we assume (and in many real cases its true), that users and models related to each other as many-to-many.
Two questions arise - how will we send these data in JSON, and how would we ideally want them to appear in our models layer. Lets start with JSON for the users. Obvious solution would be the one as follows:
const users = [{
"id" : 1,
"login" : "Vini"
"roles" : [ { "id" : 1, "name" : "Admin" }, { "id" : 2, "name" : "User" } ]
},
{
"id" : 2,
"login" : "Avraham"
"roles" : [ { "id" : 1, "name" : "Admin" }, { "id" : 2, "name" : "User" } ]
}, ... ]
So, our user's model in this case will look like this:
const Role = Model.extend({
attributes : {
name : String
}
});
const Users = Model.extend({
attributes : {
login : String,
roles : Role.Collection
}
});
Obvious problem is that we're transferring the same roles multiple times, which increase the traffic.
Less obvious problem is that when objects for role 1
will be deserialized, they will actually become different objects
in every user model. So, when one of them needs to be modified, it should be modified for every user.
In short, as relational DB developer would say, these data are not normalized.
So, the best thing we could do, is to normalize them, putting roles in separate collection. So, our 'users collection' becomes 'users directory object'.
{
"users" : [
{
"id" : 1,
"login" : "Vini"
"roles" : [ 1, 2 ]
},
{
"id" : 2,inter
"login" : "Avraham"
"roles" : [ 1, 2 ]
}, ...
],
"roles" : [ { "id" : 1, "name" : "Admins" }, { "id" : 2, "name" : "Users" }, ... ]
}
Now JSON looks okay, and we face the second problem. How would we want it to appear in our data layer?
I would say, it would be an ideal case if the user of the data layer wouldn't notice this complexity at all. If it will look for us like full collection of roles, and as nested collections of roles in every user model (but roles in this nested collection will be actually the references to shared objects in full collection of roles), it would be just fine.
Likely, this is just the thing which NestedTypes allows you to do. It will require some changes to our model definition, though:
const Role = Model.extend({
attributes : {
name : String
}
});
const Users = Model.extend({
attributes : {
login : String,
roles : Role.Collection.subsetOf( '~roles' )
}
});
const UsersDirectory = Store.extend({
attributes : {
users : User.Collection,
roles : Role.Collection
}
})
There are two new things here - Role.Collection.subsetOf( '~roles' )
and Store
. Lets understand what does it mean.
CollectionType.subsetOf( path )
spec literally means that the type of an attribute is the given collection, which will
consists of an elements from different collection with a given path
relative to model's this
. ~
is the shortcut
for getStore()
method, so ~roles
path will be resolved to this.getStore().roles
. getStore()
method, in turn,
will locate the nearest store model traversing object ownership tree upwards, until it will find something which
extends Store
base class.
Speaking simply, ~roles
reference points to the roles
attribute of the Store
model, which is the closest parent
of our model. This semantic is important as it allows us to have multiple store instances in the system at the same time.
Now, how this code works. subsetOf
attributes are always serialized as an arrays of model ids. So, it will take
array of roles ids from the JSON, and wait for the moment you will actually try to read it. And when you read
user.roles
attribute for the first time, it will take master collection from the path you specified,
and resolve ids to real models.
Speaking simply, user.roles
will appear to you as if it would be the regular Roles.Collection
. And it's the key
point of design that you should notice nothing strange.
There are some differences in behaviour, due to the fact that subsetOf
is treated as relation, not aggregation:
Collection.subsetOf
is considered as changed (and throws 'changes' event) only in case its elements are being added or removed. No member models changes will trigger collection change, and parent model change.- When
Collection.subsetOf
is deep cloned, it doesn't clone its elements. - You can use model ids in the place of the models in
set
andadd
methods. - As it was mentioned above, it's serialized to an array of ids.
Okay. But what if you want both user and role to reference each other? Yep, you can do it too using
the special form of extend
for forward declaration:
// Make forward declarations of the models to allow recursive definition...
const Role = Model.extend(),
Users = Model.extend();
Role.define({
attributes : {
name : String,
users : User.Collection.subsetOf( '~users' )
}
});
User.define({
attributes : {
login : String,
roles : Role.Collection.subsetOf( '~roles' )
}
});
const UsersDirectory = Store.extend({
attributes : {
users : User.Collection,
roles : Role.Collection
}
});
Lets say, we have some content created by users. We have few authors, and it would probably be not so bad idea to
reference author of this content in JSON by user id
. However, we don't like an idea that all our collections
would be bound to the same store as UserDirectory.
First obvious thing we could do to resolve this situation, is to create some global default store
holding all 'dictionary' data which would be used
to resolve such an ids across the system. Lets create such a store:
Nested.store = new UsersDirectory();
Once we have default store, it will be used in all cases when ownership store lookup fails. So, we can just define our standalone BlogPost model.
But wait. BlogPost have just one author, not many. Thus, we don't need Collection
, which is subsetOf
something.
We need Model
, taken from
collection.
const BlogPost = Model.extend({
attributes : {
author : Model.from( '~users' ),
created : Date,
title : '',
body : ''
}
});
let posts = new BlogPost.Collection();
posts.fetch();
Whenever we have default store populated with data, this post.author
attribute will be indistinguishable from regular
nested model attribute. But as with subsetOf
, it's not an aggregation, but relation. So, there are some differences:
Model.from
is considered as changed (and throws 'change:attr' event) only in case its model is replaced. No nested model changes will trigger attribute change, and parent model change.- When
Model.from
is deep cloned, it doesn't clone its element. - You can assign model
id
to this attribute. - It's serialized as just model ids.
There is one problem, however, which you should be aware of. In case when role is being deleted from master collection, you will need to reconcile users collection, in order to remove extra users. So far, there are no built in mechanics for that, and an easiest way of doing this is just to fetch UsersDirectory from server again.
Or (if it really important) you can do something like in the example below.
const UsersDirectory = Model.extend({
attributes : { ... },
initialize(){
this.listenTo( this.roles, 'remove', role => {
this.users.transaction( () => { // 'users' will trigger single 'changes' event for bulk operation
this.users.each( user => user.roles.remove( role ) );
});
});
}
});
Or ask us to add generic reconcile
method. In older NestedTypes
versions it was hard to do efficient enough to make generic implementation, and also its quite rare situation
when you really need client-side reconcilation; that's why it's not done yet. But now we can.
Now let's imagine the situation that we have a lot of authors and posts, and we can't afford to load everything upfront. So we want to utilize paging. Still, we have an intention to pack users in JSON separately from the posts.
We can do this using the same trick as we done for users and roles originally, no matter do we have default store or not.
Lets just add one more store for our task. For this case, we will create the model for every page with posts.
This model id
will be the page id.
const PostsPage = Store.extend({
attributes : {
posts : BlogPost.Collection,
users : User.Collection
}
});
Now, all ~users
references from page.posts
will use local page.users
collection. But ~roles
references from
page.users
will find no collection for roles, so what will happen? They will fall back to the default store we have
defined above, and take roles from there.
In nested types, you may have multiple stores at the same time, you can dynamically create them, and they plays together:
- Whenever closest parent lookup for the store fails, default store is taken.
- Stores can aggregate other stores.
- Whenever lookup for the resource in particular store fails...
- parent store is taken, and procedure continues;
The last thing about our design which is not so good - we're still forced to load everything upfront in our default store. Which is still UserDirectory so it contains users and roles. It's not so good; our paged posts list is not interested in all users.
What we would probably like to have in this situation, is an ability to load default store elements with separate REST requests, when we need them. And this strategy will become more important when our application will grow large.
const UsersDirectory = LazyStore.extend({
attributes : {
users : User.Collection,
roles : Role.Collection
}
});
LazyStore
manage enclosed models and collections in following way:
- It expect that any resource in its attributes can be loaded with
fetch()
. - You can tell
LazyStore
to load specific resources withstore.fetch( 'users', 'other', ... )
call. - Or you can fetch all resources at once with
fetch()
without arguments. fetch
returns combined promise, so you can track when I/O is finished.- If you're too
Lazy
, you can skip previous items.LazyStore
will load it for you automatically on first access attempt. - You can clean up the store with
store.clean()
to reclaim memory. Or pass specific resource names as infetch
.
As with other stores, there might be as many instances of LazyStore
created dynamically as you wish.
The fact that reference path
is taken relative to model's this
allows us to use local relations in scenarious wihout Stores, which are way simpler and more common than example discussed above. Most of these scenarious happens when you use NestedTypes not in data layer, but to model UI state. Which is very convinient due to deep changes detection feature, and invaluable in sutuations when you need to preserve UI state on browser refresh.
It's hard to write about UI patterns in general, because different frameworks has different assumptions on what is UI state. So, the case of React is covered in the last section, and React is actually the recommended way of writing applications with NestedTypes.
Suppose that we have a list of items in collection, which we need to display. And some items might be selected with a click.
It could be done with DOM manipulation, and we can rely on the DOM as the primary source of information about selection. Which is extrimely bad practice. In case of any UI framework the code will be much cleaner if we would keep information about selection (and other information which is required to render the widget) as a part of the separately managed UI state. And here the situation our models and relations comes to help.
First idea which comes to the mind is to add 'selected' attribute to the item's model. And again, it is bad idea no matter which framework you're using. In this case we would mix UI state with our data layer. Server and other part of our application have no interest in selection made in particular UI widget, so we need to keep it separate from the items
collection.
Thus, we introduce collection of selected items, which is, obviously, the subset of items collection, and put it along with items we wanna render:
const State = Model.extend({
attributes : {
items : Collection,
selected : Collection.subsetOf( 'items' )
}
})
Here, since master collection's path is taken relative to this
, it will be this.items
. And this spec gives reader quite precise information about the purpose of this selected
.
Then we can just subscribe for the changes of this model and update our UI on every change. Thanks to NestedTypes
deep changes detection feature, whenever we will receive items from the server or anything will be changed deep inside of the models for any reason, our UI will be in sync. In case of Backbone View, it will look like this:
initialize : function( options ){
this.model = new State( options );
this.listenTo( this.model, 'change', this.render );
}
So, instead of DOM manipulation, now it's enough to add or remove corresponding item in selected
collection in our click event handler. It has toggle
method for that purpose, like selected.toggle( modelOrId )
. And since selected
collections knows which subset it is, it can easily handle toggle
with model id taken from the DOM as an argument.
onClick : function( e ){
const id = $( e.target ).attr( 'model-id' );
this.model.selected.toggle( id ); // will trigger state model change, which will trigger UI update
}
If just one item may be selected at a time, it will obviously be
Model.from( 'items' )
instead ofCollection.subsetOf
.
Now let's suppose that we need to preserve our selection in local storage when browser is refreshed.
First idea is to take some Backbone plugin for working with localStorage
, and try it on our model. As usual. Bad idea. This time - because it could be done trivially without plugins. Thanks to powerful NestedTypes
serialization facilities, it's enough to convert our State model to JSON and save it as one piece.
Lets do something quick and dirty to illustrate an idea. First, we need to teach our model to save to and be loaded from local storage. We suspect it won't be the single case, so we create the base class for that.
const LocalStorage = Model.extend({
fetch(){
if( this.id ){ // take model id as key...
const json = localStorage.getItem( this.id );
json && ( this.set( JSON.parse( json ), { parse: true }) );
}
},
save( attrs ){
attrs && this.set( attrs );
this.id && localStorage.setItem( this.id, JSON.stringify( this ) );
}
});
Great. When it comes to the State
model, we probably don't want to save items
to local storage, because they are received from the server. Just selected
. And thanks to subsetOf
metatype, it will be serialized as an array of model ids, and it's exactly what we want. So...
const State = LocalStorage.extend({
attributes : {
id : 'My Very Specific Widget State Local Storage Key',
items : Collection.has.toJSON( false ),
selected : Collection.subsetOf( 'items' )
}
})
Assuming that we will have just one instance of this widget on the screen, it's okay, so it would be enough to add something like this in widget's constructor:
this.model.fetch();
window.onunload = () => this.model.save();
That's really quick and very dirty example (describen trick with local storage is really okay for the top level View only), but I think you got the general idea.
...this example would look a bit different. Because React handle state differently (and in much better way) than traditional MVC frameworks. And because we have special support for React (you're also warmly welcomed to see our complete React TodoMVC example).
Here props
will be translated to propTypes
, state
will lead to creation of the NestedTypes model to manage state (as you see both have the common type annotation style), Model
specify the base class for the state model, and pureRender
... Well, it's pure render, which you've probably been told of as an impossible optimization for mutable data. :) So now you know it was not true.
In the context of our topic, you might wander what ^props.items
reference means. ^
is the shortcut for getOwner()
call, thus this reference will be translated to this.getOwner().props.items
, which literally means the reference to the member of my component's props
. Model can make direct references to its parents too, yes. I wouldn't recommend using them in other context, though.
const QuickAndDirty = React.createClass({
props : {
items : Collection
},
pureRender : true,
Model : LocalStorage,
state : {
id : 'My Very Specific Widget State Local Storage Key',
selected : Collection.subsetOf( '^props.items' )
},
componentWillMount(){
this.state.fetch();
},
componentWillUnmount(){
this.state.save();
},
...
});