Shaun Xu

The Sheep-Pen of the Shaun


News

logo

Shaun, the author of this blog is a semi-geek, clumsy developer, passionate speaker and incapable architect with about 10 years’ experience in .NET and JavaScript. He hopes to prove that software development is art rather than manufacturing. He's into cloud computing platform and technologies (Windows Azure, Amazon and Aliyun) and right now, Shaun is being attracted by JavaScript (Angular.js and Node.js) and he likes it.

Shaun is working at Worktile Inc. as the chief architect for overall design and develop worktile, a web-based collaboration and task management tool, and lesschat, a real-time communication aggregation tool.

MVP

My Stats

  • Posts - 111
  • Comments - 432
  • Trackbacks - 0

Tag Cloud


Recent Comments


Recent Posts


Article Categories


Archives


.NET



In Worktile Pro we are using MongoDB and Mongoose as the backend database with Mongoose Node.js module for ODM (Object Document Mapping) framework.

Although Mongoose brings "Schema" back it still provides some advantages such as validation, abstraction, reference and default value. In this post I will talk about the default value based on a bug I found when developing Worktile Pro.

 

We can define default value against a model  schema. In the code below I have a blog schema, with default value against "created_by" and "created_at" properties.

1
2
3
4
5
let BlogSchema = new Schema({
    title: {type: String},
    created_by: {type: String, default: 'Shaun Xu'},
    created_at: {type: Number, default: Date.now}
});

If we tried to add a new blog document without specifying them, Mongoose will use default value as below.

1
2
3
4
5
6
7
8
let blog1 = new BlogModel({
    title: 'Implement Schema Inherence in Mongoose'
});

co(function *() {
    let savedBlog1 = yield blog1.save();
    console.log(`Blog: ${JSON.stringify(savedBlog1, null, 2)}`);
});

 

But if we specified "null", they will be marked as "defined" so that Mongoose will set "null" rather than the default value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let blog2 = new BlogModel({
    title: 'Angular Toolkits: Customizable Bootstrap Tabs',
    created_by: null,
    created_at: null
});

co(function *() {
    let savedBlog2 = yield blog2.save();
    console.log(`Blog: ${JSON.stringify(savedBlog2, null, 2)}`);
});

 

How about "undefined"? Let's look at the code below. Will Mongoose use default value?

1
2
3
4
5
let blog3 = new BlogModel({
    title: 'Pay Attention to "angular.merge" in 1.4 on Date Properties',
    created_by: undefined,
    created_at: undefined
});

The result was shown below, Mongoose didn't use default value for "blog3".

Screen Shot 2016-01-26 at 17.36.27

This means even though we set property to "undefined", Mongoose will NOT use default value. This is because Mongoose just check if the document object contains properties. If yes, no matter it has value, it's "null", or even it's "undefined", it was defined. So Mongoose will NOT use default value.

 

So finally rule: If you want Mongoose to use default value on a property, do NOT specify it in the document object, even though set it to "null" or "undefined".

 

Hope this helps,

Shaun

All documents and related graphics, codes are provided "AS IS" without warranty of any kind.
Copyright © Shaun Xu. This work is licensed under the Creative Commons License.


When I was developing Worktile Enterprise we are using MongoDB with an ODM framework called Mongoose. It provides sachems-based validation in top of native MongoDB driver, and for each collection we also need to define schema.

When the business became more complex we found it needs to share part of schema between collections. For example, in calendar module there are two collection one for event series the other for each individual event instance. Both of them contains properties such as event name, description, location, start and end date and so on. One solution is to have them defined in both schemas. But this introduces duplication in our codebase. And if we need to tweak the definition we need to remember to modify code in, at least, two places. With more business requirements came we know there will be more cases needs to share schema. This solution is easy to do, but will harm our source code very quickly.

 

Shared Schema as Sub-Document

Another solution is to move the shared definition in a separate file and export it. Then we can require and put it as a sub-document when we need. Below is the shared calendar event details definition in our Worktile Enterprise.

 

1
2
3
4
5
6
7
8
9
name: {type: String},
start: {type: Number},
end: {type: Number},
is_all_day: {type: Number, default: core.constant.calendar.isAllDayEvent.no},
location: {type: String},
video_link: {type: String},
description: {type: String},
reminders: [ReminderShared.Reminder],
participants: [Participant]

 

Now when we defined event series schema we required the code we created previously and put it into the schema property named "details".

 

1
2
3
4
5
6
var Shared = require('./calendar.shared');

var CalendarEventSchema = new Schema({
    calendar: {type: Schema.ObjectId, ref: 'calendar', index: true},
    details: Shared.Details
});

 

Similarly, in event instance schema there is a property named "details" and referring our shared schema as well.

 

1
2
3
4
5
6
7
8
var Shared = require('./calendar.shared');

var CalendarEventInstanceSchema = new Schema({
    calendar: {type: Schema.ObjectId, ref: 'calendar', index: true},
    event: {type: Schema.Types.ObjectId, ref: 'calendar_event'},
    seqno: {type: Number},
    details: Shared.Details
});

 

In this solution we defined the shared schema in one place and reference in any places we need. And if we changed the content of the shared schema all collections referring it will be changed automatically. I was using it in several collections and it helped me a lot, until I began to implement the reminder module.

Worktile Enterprise contains a dedicate reminder component which responsible for notify users when an event is going to start, or a task is reaching the deadline, through vary channels such as email, SMS and voice call.

In reminder component we have two collections one for outstanding reminder records while the other stores failed reminders (we called reminder-poison) for future diagnostic.

Reminder-poison collection has almost same information as reminder collection, with three additional properties: poisoned_at, poison_reason and poison_reason_description. Same reason, we don't want to refine shared schema twice. But in this case it may not be a good choice to put shared schema in a property because reminder collection contains the entire share schema, which will make the document looks strange.

 

1
2
3
var ReminderSchema = new Schema({
    details: Reminder.Shared
});

 

In order to make the shared properties in the same level (not in a sub document) we introduced another solution.

 

Shared Schema as Assigned Properties

Basically Mongoose schema definition is a normal JSON object, each property represents a MongodDB document property with additional information such as type, index and reference. It we can load the shared schema JSON, copy its properties and combine to the targeting schema then we can implement inherience.

For example, assuming we have shared schema as below.

 

1
2
3
4
5
var PersonSchemaRaw = {
    first_name: {type: String},
    last_name: {type: String},
    age: {type: Number}
};

 

Then we can inherits schema from it and append additional properties like this.

 

1
2
3
4
5
6
7
8
9
var UserSchemaRaw = (function () {
    var schema = {};
    Object.keys(PersonSchemaRaw).forEach(function (key) {
        schema[key] = PersonSchemaRaw[key];
    });
    schema.email = {type: String},
    schema.avatar = {type: String}
    return schema;
})();

 

If have "lodash" required in your system it could be more easy and simple as below.

 

1
2
3
4
var UserSchemaRaw = _.assign({}, PersonSchemaRaw, {
    email: {type: String},
    avatar: {type: String}
});

 

Related Topics

There's a Mongoose plugin named mongoose-schema-extend implements the schema inherence. You need to request Monggose and this plugin, then define your schema with base schema through "extend" method as below.

 

1
2
3
4
5
6
7
var PersonSchema = new Schema({
  name : String
}, { collection : 'users' });

var EmployeeSchema = PersonSchema.extend({
  department : String
});

 

If your project is fine with ES6, there's a new MongoDB ES6 Node.js client named Mongorito. Although it said it's not necessary to define a model before operating against a collection, you can even do that with the new ES6 Class syntax as below.

 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var Mongorito = require('mongorito');
var Model = Mongorito.Model;

class Person extends Model {
    // ... ...
}

class User extends Person {
    // ... ...
}

 

Since all models in Mongorito are inherits from "Model", you can define model which inherits from another model you defined.

 

Summary

MongoDB is schema-free which means we don't need to define which properties should be defined in a collection. It should be admitted that this looks like attempting to use MongoDB as a relational database. But schema-predefinition provides some additional benefit such as validation, population, etc.

When we need some common schema across multiple collections we have several solutions. We can repeat them in each schemas, or put then shared properties into a sub document. Or if we want them to be in the same level as other properties we can leverage "lodash" to combine.

We can, of course use mongoose-schema-extend and Mongorito if we can use ES6.

 

Hope this helps,

Shaun

All documents and related graphics, codes are provided "AS IS" without warranty of any kind.
Copyright © Shaun Xu. This work is licensed under the Creative Commons License.


Bootstrap provides a component named Tabs, which provides tab container and tab pages. In order to make it easy to use in Angular.js, Angular UI team created a directive called "ui.bootstrap.tabs". By using this directive we can define a bootstrap tab component, we can also specify the content in each tab pages. But the problem is, all tabs' content must be in the same scope. For example, below is the sample code from "ui.bootstrap.tabs". As we can see, I must put all tabs' content in the same page, and using the same scope.

   1: <tabset>
   2:   <tab heading="Static title">Static content</tab>
   3:   <tab ng-repeat="tab in tabs" heading="{{tab.title}}" active="tab.active" disable="tab.disabled">
   4:     {{tab.content}}
   5:   </tab>
   6:   <tab select="alertMe()">
   7:     <tab-heading>
   8:       <i class="glyphicon glyphicon-bell"></i> Alert!
   9:     </tab-heading>
  10:     I've got an HTML heading, and a select callback. Pretty cool!
  11:   </tab>
  12: </tabset>

 

In my project, I would like to have a page with a tab container, but each tab's template (HTML) and business logic (controller) can be defined separately. Something looks like this.

   1: <tabset>
   2:     <tab template="tabs/tab-1.html" controller="Tab1Ctrl"></tab>
   3:     <tab template="tabs/tab-2.html" controller="Tab2Ctrl"></tab>
   4:     <tab template="tabs/tab-3.html" controller="Tab3Ctrl"></tab>
   5: </tabset>

And this component should be smart enough to load content and execute business logic when user active this tab. So this is the reason I created this directive.

 

Features

- Tab's content can be defined through inline HTML string or an external HTML file.

- Tab's controller can be specified inline or by name.

- Each tab contains its own scope, but can share objects through "$scope.$context.data" property.

- Tabs can be close or open.

- Tab's content and logic is lazy load (render and execute only when the tab was shown).

- Provides some handlers that can be hooked when a tab was opened, closed, entered and left.

ezgif.com-optimize

 

Installation & 1st Tab

First you need to reference tow files from GitHub.

https://github.com/shaunxu/angular-toolkits/blob/master/tabs/sx-tabs-tpls.js

https://github.com/shaunxu/angular-toolkits/blob/master/tabs/sx-tabs.js

 

Then you need to reference some basic libraries I think you have done before.

- jQuery

- Bootstrap

- Angular.js

 

In your Angular.js application module, specify the dependency of "sx.tabs" as below.

   1: window.app = window.angular.module('Demo', [
   2:     'YOUR-DEPENDENCY-MODULE', 
   3:     'YOUR-DEPENDENCY-MODULE', 
   4:     ... ...
   5:     'YOUR-DEPENDENCY-MODULE', 
   6:     'sx.tabs'
   7: ]);

And now you can define tabs in your page as below.

   1: <div sx-tabs="tabs" 
   2:      sx-tabs-context="context" 
   3:      sx-tabs-enabled="onTabEnabled(tab)" 
   4:      sx-tabs-disabled="onTabDisabled(tab)" 
   5:      sx-tab-switched="onTabSwtiched(tab)">
   6: </div>

The meaning of the attributes are described as following.

- sx-tabs: Tabs' definition. Details will be discussed later.

- sx-tabs-context: Data will be passed into each tabs' controller scope. It will be shared between all tabs.

- sx-tabs-enabled: A function will be invoked when a tab was shown from the "more" button.

- sx-tabs-disabled: A function will be invoked when a tab was hidden into the "more" button.

- sx-tabs-switched: A function will be invoked when a tab was activated.

 

Next, you need to define tabs in your controller scope, with the property name specified in previous step. In this case it's "$scope.tabs". In the code below I defined a tab with ID = "general".

   1: var tabs = {
   2:     'general': {
   3:         id: 'general',
   4:         title: 'General',
   5:         order: 1110,
   6:         enabled: true,
   7:         templateUrl: 'tabs/general.html',
   8:         controller: 'sxTabGeneralCtrl'
   9:     }
  10: };
  11: $scope.tabs = tabs;

Each tab definition must contain properties as below.

- id: A unique string to identify this tab in this tab container.

- title: Tab title that will be displayed in the tab bar.

- order: Defines the display sequence of tabs. Tab with lower order will be displayed on the left.

- enabled: Tab will be shown by default when it's true, otherwise it will be hidden in "more" button.

- templateUrl: HTML path for the tab content.

- template: Inline HTML string for the tab content. This value will override "templateUrl".

- controller: Inline function or controller name of this tab. This property is optional.

 

In "sx.tabs" each tab has its own controller and scope, that you can specify your own business logic in each tab without messing them up. In this tab we defined "$scope.productName" in controller.

   1: (function () {
   2:     window.app.controller('sxTabGeneralCtrl', function ($scope) {
   3:         $scope.productName = 'sx.tabs';
   4:     });
   5: }())

And display in "tabs/general.html".

   1: <div class="jumbotron" style="background-color: #fff;">
   2:   <h1>Hello, tabs!</h1>
   3:   <p>Welcome to use "{{productName}}" in Shaun's Angular Toolkits</p>
   4: </div> 

Now the tab will look like this. It contains one tab with almost no logic.

image

 

Shared Data

As I mentioned above, you can pass objects into tabs and it will be shared across all tabs. To do this, you need to define the data in parent controller scope and pass through the "sx-tabs-context" attribute. For example below is the data.

   1: var context = {
   2:     user: 'Shaun',
   3:     company: 'Worktile Inc.',
   4:     department: 'Web'
   5: };
   6: $scope.context = context; 

And specify the tab context through its directive attribute named "sx-tabs-context". Then I can retrieve this object from tab's controller scope through "$scope.$context.data". For example below I created another tab.

Note that in this tab I didn't specify controller, since it doesn't need any logic.

   1: var tabs = {
   2:     'general': {
   3:         ... ...
   4:     },
   5:     'shared-data': {
   6:         id: 'shared-data',
   7:         title: 'Shared Data',
   8:         order: 1120,
   9:         enabled: true,
  10:         templateUrl: 'tabs/shared-data.html'
  11:     }
  12: }

And in the template HTML and retrieve my shared data from "$scope.$context.data" and bind with several inputs.

   1: <form>
   2:   <div class="form-group">
   3:     <label for="user">User</label>
   4:     <input type="text" class="form-control" id="user" placeholder="User" ng-model="$context.data.user">
   5:   </div>
   6:   <div class="form-group">
   7:     <label for="company">Company</label>
   8:     <input type="text" class="form-control" id="company" placeholder="Company" ng-model="$context.data.company">
   9:   </div>
  10:   <div class="form-group">
  11:     <label for="dept">Department</label>
  12:     <input type="text" class="form-control" id="dept" placeholder="Department" ng-model="$context.data.department">
  13:   </div>
  14: </form>

Now we have two tabs and in the second one, you can see the data I defined in parent scope was passed into tab scope.

image

And since the are referring the same object, any changes inside one tab will affect all other tabs as well as the parent scope.

image

 

Tab Entering & Leaving

A tab will be rendered and performs its controller when the first time it was shown, no matter by clicking the tab title, or from the "more" button. This enhance the performance of the whole tab container. But it will never be rendered again.

In some cases we need the tab to load some data and update the page when it's switched. What we can do is to implement our own tab entering function.

To show this feature I created another tab, which was similar as the previous one.

   1: var tabs = {
   2:     'general': {
   3:         ... ...
   4:     },
   5:     'shared-data': {
   6:         ... ...
   7:     },
   8:     'enter-validate': {
   9:         id: 'enter-validate',
  10:         title: 'Load & Validation',
  11:         order: 1150,
  12:         enabled: true,
  13:         templateUrl: 'tabs/enter-validate.html',
  14:         controller: 'sxTabEnterValidateCtrl'
  15:     }
  16: }

In the controller I defined a function inside "$scope.$context.behavior" called "entering". It will be invoked when this tab was activated, with two arguments "options", and "callback".

The "options" contains information about this entering event as below.

- fromTabId: The ID of which tab switched from. It might be null if this is the first visible tab.

- entered: Indicates whether this tab had been shown before. It can be used to avoid loading data duplicated.

The "callback" argument is a function that MUST be invoked when entering logic was finished, regardless if it's success or failed.

Below is the code I implemented in my tab. It utilizes "$timeout" to simulate async loading. It also checked "options.entered" to ensure load data only when this tab was entered first time.

   1: (function () {
   2:     window.app.controller('sxTabEnterValidateCtrl', function ($scope, $timeout) {
   3:         $scope._entering = false;
   4:         
   5:         $scope.$context.behavior.entering = function (options, callback) {
   6:             $scope._entering = true;
   7:             if (options.entered) {
   8:                 $scope._entering = false;
   9:                 return callback ();
  10:             }
  11:             else {
  12:                 $timeout(function () {
  13:                     $scope._entering = false;
  14:                     return callback();
  15:                 }, 1000);
  16:             }
  17:         };
  18:     });
  19: }())

 

Similar as entering handler, we can implement "$scope.$context.behavior.leaving" to perform our own logic when a tab is going to be left. This could be useful if we want to prevent user from leaving this tab if any inputs were invalid.

"leaving" function also contains "options" and "callback" arguments. The details of "options" is

- toTabId: The ID of which tab it will be switched to.

- byTabDisable: A boolean value to indicates whether this leaving is because user hide this tab.

Same as "entering", "callback" function MUST be invoked when leaving logic was finished. It contains one arguments to indicates if it can be left or not. For example, if validation was failed, we need to call "return callback(false)" to prevent user leaving this tab.

Below is the code I implemented in the sample tab. I utilizes Angular.js form validation module to check if user can left this tab.

   1: $scope.$context.behavior.leaving = function (options, callback) {
   2:     $scope._leaving = true; 
   3:  
   4:     if (options.byTabDisable) {
   5:         $scope._leaving = false;
   6:         return callback(true);
   7:     }
   8:     else {
   9:         $timeout(function () {
  10:             $scope._leaving = false;
  11:             return callback($scope.inputForm.$valid);
  12:         }, 1000);
  13:     }
  14: };

Now if you clear any inputs in this tab you cannot switch to any other tabs.

image

 

Show & Hide Tabs

Tabs can be hidden by clicking its close icon and can be shown again from the "more" button.

image

We can also define a tab which will be hidden by default. Just set its "enabled" property to "false". For example in the code below I defined 3 tabs with "enabled: false".

   1: var tabs = {
   2:     'general': {
   3:         ... ...
   4:     },
   5:     'shared-data': {
   6:         ... ...
   7:     },
   8:     'enter-validate': {
   9:         ... ...
  10:     },
  11:     'more-tabs-1': {
  12:         id: 'more-tabs-1',
  13:         title: 'More Tabs 1',
  14:         order: 1210,
  15:         enabled: false,
  16:         template: '<h1>More Tabs 1</h1>'
  17:     },
  18:     'more-tabs-2': {
  19:         id: 'more-tabs-2',
  20:         title: 'More Tabs 2',
  21:         order: 1220,
  22:         enabled: false,
  23:         template: '<h1>More Tabs 2</h1>'
  24:     },
  25:     'more-tabs-3': {
  26:         id: 'more-tabs-3',
  27:         title: 'More Tabs 3',
  28:         order: 1230,
  29:         enabled: false,
  30:         template: '<h1>More Tabs 3</h1>'
  31:     }
  32: };

Then it will be hidden.

image

 

You can also hook the events when user show, hide a tab, as well as when a tab was switched. This might be useful if you want to save the tab status into user's preference so that next time user can see tabs she opened previously.

To accomplish just specify which function these events will invoke.

   1: <div sx-tabs="tabs" 
   2:      sx-tabs-context="context" 
   3:      sx-tabs-enabled="onTabEnabled(tab)" 
   4:      sx-tabs-disabled="onTabDisabled(tab)" 
   5:      sx-tab-switched="onTabSwtiched(tab)">
   6: </div>

And implement those functions in your parent scope as below.

   1: $scope.onTabEnabled = function (tab) {
   2:     $scope.messages.push('Tab enabled: ' + tab.id);
   3: };
   4: $scope.onTabDisabled = function (tab) {
   5:     $scope.messages.push('Tab disabled: ' + tab.id);
   6: };
   7: $scope.onTabSwtiched = function (tab) {
   8:     $scope.messages.push('Tab switched: ' + tab.id);
   9: };

Then in the page you can see events fired from the directive. You can hook any of them and perform you logic.

image

 

Promise Support

The last feature I would like to mention, this directive support specify "tabs" and "context" as a promise. This is very useful when the tab definition and shared data must be retrieved asynchronous.

For example, I utilizes "$timeout" to simulate the case that retrieve "context" asynchronous.

   1: var context = {
   2:     user: 'Shaun',
   3:     company: 'Worktile Inc.',
   4:     department: 'Web'
   5: };
   6: $scope.context = $q(function (resolve, reject) {
   7:     $timeout(function () {
   8:         return resolve(context);
   9:     }, 1000);
  10: });

Similarly, "tabs" can be a promise as well.

   1: $scope.tabs = $q(function (resolve, reject) {
   2:     $timeout(function () {
   3:         return resolve(tabs);
   4:     }, 1000);
   5: });

The directive is smart enough to handler promise case, or normal object case.

 

Summary

In this post I introduced an Angular.js directive I created and am using in my project, which is a wrapper of Bootstrap tabs with more customization features.

Different from the tabs implemented in UI-Bootstrap, you can define tab's template and controller separately. And it will be loaded, rendered when the tab was activated. You can also pass objects that can be shared through all tabs, but each tab scope are isolated. It supports customize entering and leaving function, tab show, hide and switch function. It also accept tab definition and shared data as object or promise.

You can find the online demo at this plunker. You can find the source code in GitHub. If you found any bugs or any feature requirements please feel free to open issues.

 

Hope this helps,

Shaun

All documents and related graphics, codes are provided "AS IS" without warranty of any kind.
Copyright © Shaun Ziyan Xu. This work is licensed under the Creative Commons License.


I started to use Geekwithblogs (a.k.a. GWB) since 2010, based on one of my friend's recommendation. I've to say during the past 5+ years I was really enjoying blogging and had published 107 posts with 380 comments. GWB provided an awesome platform where I can share my experience and discuss with a lot of talents.

 

But since last month I found my blog look strange. On May 29th I found all my categories are lost. And when I tried to create a new category it still cannot be saved. This means all my well-categorized 107 posts are messed up.

image

Several days later I found my gallery was emptied in admin page, too, even though I can access images stored there.

image

 

Well I think this is not a big issue. Maybe GWB was updating, or maybe my site was hacked. So when I found the issue on May 29th I tried to contact Jeff Julian, the staff of GWB who helped me to map blog.shaunxu.me to my blog before. But no response till now.

Then I tried to find any channels to the team of GWB, but no luck. There seems no entry or link on geekswithblogs.net, or in admin page mentions how to contact them. Finally I tried to use the "Suggest" link on geekswithblogs.net and posted an item, but still no reply till now.

image

 

Today I suddenly found my blog theme was changed. After resumed the theme I think this might be the only way to report my problem, which is to publish a post. Sorry if I border you but I really want to check what's going on with GWB? Is there anyone who is still maintaining this site?

 

Hope anyone can help me,

Shaun

All documents and related graphics, codes are provided "AS IS" without warranty of any kind.
Copyright © Shaun Ziyan Xu. This work is licensed under the Creative Commons License.


Today when I upgraded my application from Angular.js 1.3.9 to the latest 1.4, I got some bugs. After investigated a bit I found they are related with date properties and "angular.merge" function, which was included in 1.4.

In the official document, "angular.merge" is

Deeply extends the destination object "dst" by copying own enumerable properties from the "src" object(s) to "dst".

It also mentioned the different with "angular.extend", which had been included in previous versions.

Unlike "extend()", "merge()" recursively descends into object properties of source objects, performing a deep copy.

 

Let's have a look on a very simple example. In code below I have a source object and custom object defined in scope. And I used "angular.extend" to copy custom object into source.

   1: $scope.source = {
   2:   name: 'Shaun',
   3:   age: 35,
   4:   birthDate: new Date(1997, 5, 27),
   5:   skills: {
   6:     dotNet: {
   7:       level: 'expert',
   8:       years: 10
   9:     },
  10:     javaScript: {
  11:       level: 'newbie',
  12:       years: 2
  13:     }
  14:   },
  15:   mvp: [
  16:     2011,
  17:     2012,
  18:     2013,
  19:     2014,
  20:     2015
  21:   ]
  22: };
  23:  
  24: $scope.custom = {
  25:   name: 'Ziyan',
  26:   age: 35,
  27:   skills: {
  28:     dotNet: {
  29:       level: 'hero', 
  30:       years: 100,
  31:       active: true
  32:     },
  33:   },
  34:   mvp: [
  35:     2016
  36:   ]
  37: };
  38:  
  39: $scope.extend = angular.extend({}, $scope.source, $scope.custom);

From the result we can see, since "angular.extend" performs shallow copy, primitive value property such as "name", "age" and "bitrhDate" are merged. But since "skills" and "mvp" are object and array, "angular.extend" will just copy the entire value, rather than their members.

image

 

Now let's using "angular.merge".

   1: $scope.merge = angular.merge({}, $scope.source, $scope.custom);

Now we can see, when using "angular.merge", it will copy object properties recursively.

image

 

Everything looks great till now. But someone may find when using "angular.merge", one of the property, "birthDate" was been set as am empty object.

image

If we deep into the source code of Angular.js we will find, both "angular.extend" and "angular.merge" are invoking an internal function named "baseExtend". It merges objects to destination object, with a flag parameter indicates whether it's a deep merge or not.

Inside this function, it loops each enumerable properties, try to copy the value to destination object. If the source property is an object and need to be deeply copied, Angular.js will create an empty object in destination and perform this function against this property recursively.

   1: function baseExtend(dst, objs, deep) {
   2:   var h = dst.$$hashKey;
   3:  
   4:   for (var i = 0, ii = objs.length; i < ii; ++i) {
   5:     var obj = objs[i];
   6:     if (!isObject(obj) && !isFunction(obj)) continue;
   7:     var keys = Object.keys(obj);
   8:     for (var j = 0, jj = keys.length; j < jj; j++) {
   9:       var key = keys[j];
  10:       var src = obj[key];
  11:  
  12:       if (deep && isObject(src)) {
  13:         if (!isObject(dst[key])) dst[key] = isArray(src) ? [] : {};
  14:         baseExtend(dst[key], [src], true);
  15:       } else {
  16:         dst[key] = src;
  17:       }
  18:     }
  19:   }
  20:  
  21:   setHashKey(dst, h);
  22:   return dst;
  23: }

It works in almost all cases but Date. If we have a Date property defined, for example "birthDate", it will check if this property is an object by using "angular.isObject" and it will return "true". So it will create a property named "birthDate" in destination with an empty object, and invoke "baseExtend" against "birthDate". But since "birthDate" is a Date which is no enumerable property, so it will not assign any data. This is the reason we found in result, "birthDate" property is empty.

If I copied Angular.js "baseExtend" function to my scope, and changed the code as below, which will perform simple copy when the property is Date, it will work.

   1: $scope.$baseExtend = function (dst, objs, deep) {
   2:   for (var i = 0, ii = objs.length; i < ii; ++i) {
   3:     var obj = objs[i];
   4:     if (!angular.isObject(obj) && !angular.isFunction(obj)) {
   5:       console.log('[' + obj + '] = (skip)');
   6:       continue;
   7:     }
   8:     var keys = Object.keys(obj);
   9:     for (var j = 0, jj = keys.length; j < jj; j++) {
  10:       var key = keys[j];
  11:       var src = obj[key];
  12:       // perform deep copy if
  13:       // 1. spcified by user
  14:       // 2. source property is an object
  15:       // 3. source property is NOT a date
  16:       if (deep && angular.isObject(src) && !angular.isDate(src)) {
  17:         if (!angular.isObject(dst[key])) {
  18:           console.log('[' + key + '] = (Try copy an object to an non-object, create an empty for deep copy.)');
  19:           dst[key] = angular.isArray(src) ? [] : {};
  20:         }
  21:         $scope.$baseExtend(dst[key], [src], true);
  22:       } 
  23:       else {
  24:         dst[key] = src;
  25:         
  26:         console.log('[' + key + '] = ' + src);
  27:       }
  28:     }
  29:   }
  30:   return dst;
  31: };
  32:  
  33: $scope.sample = $scope.$baseExtend({}, [$scope.source, $scope.custom], true);

This is the result.

image

 

Summary

Upgrade framework to a new version is always be an adventure. We need to perform a lot of regression tests to make sure it will not break anything. This problem was found when I perform E2E tests.

I think this is bug in Angular.js and I had posted an issue. But before their verification and response, I think you should pay more attention when using "angular.merge" function in your application.

 

Hope this helps,

Shaun

All documents and related graphics, codes are provided "AS IS" without warranty of any kind.
Copyright © Shaun Ziyan Xu. This work is licensed under the Creative Commons License.