Monday, February 8, 2016

Date override

In its design, GPF-JS supports several hosts. Unfortunately, these platforms do not necessary provide all the expected EcmaScript features. Here is an example of manipulation I had to deal with in order to implement the ISO 8601 support for dates.

toISOString

The Date method toISOString has been introduced with JavaScript 1.8. It returns a string in simplified extended ISO 8601 format, which is always 24 characters long: YYYY-MM-DDTHH:mm:ss.sssZ. The time zone is always zero UTC offset, as denoted by the suffix "Z".

2018-12-07T18:32:29.664Z

The advantage of using strings to represent dates in the UTC time zone is that you can store and compare them simply. Moreover, this very specific format can be safely detected and associated to a date value inside a JSON string.

When the method does not exist in the current host, you can easily workaround the issue by:

  • Detecting the missing API on Date prototype
  • Implementing a polyfill

Most of the common polyfills are proposed on the Mozilla Developer Network. Here is the proposed one:

if (!Date.prototype.toISOString) { (function() { function pad(number) { if (number < 10) { return '0' + number; } return number; } Date.prototype.toISOString = function() { return this.getUTCFullYear() + '-' + pad(this.getUTCMonth() + 1) + '-' + pad(this.getUTCDate()) + 'T' + pad(this.getUTCHours()) + ':' + pad(this.getUTCMinutes()) + ':' + pad(this.getUTCSeconds()) + '.' + (this.getUTCMilliseconds() / 1000).toFixed(3).slice(2, 5) + 'Z'; }; }()); }

In gpf-js, this is done inside the compatibility module The detection, replacement and testing of polyfills is configured by a dictionary (_gpfCompatibility).

A counterpart of this method is that you should be able to initialize a date with the same format: var date = new Date("2003-01-22T22:45:00.000Z"); assert(2003 === date.getUTCFullYear()); assert(0 === date.getUTCMonth()); assert(22 === date.getUTCDate()); assert(22 === date.getUTCHours()); assert(45 === date.getUTCMinutes()); assert(0 === date.getUTCSeconds());

But, apparently, some of the hosts supported by gpf-js are not accepting this format (cscript & rhino).

So, I ended up facing this question: how can I override the Date constructor?

I first thought of creating a Date.fromISOString method but I realized that I would then deviate from the 'standard'. Indeed, most of modern hosts are supporting this format: I just needed to find a way for older hosts.

Replacing the Date constructor

Before going any further, there are several constraints to take into account:

  • The new constructor function object should expose the same methods as the Date one (Date.UTC ...)
  • The new constructor must be able to create real Date objects (meaning, the prototype is the same and instanceof must work)
  • Objects created with the genuine constructor should also be instances of the new one
  • Any supported use of the constructor must work

So the following examples from MDN should work

var today = new Date(); var birthday = new Date('December 17, 1995 03:24:00'); var birthday = new Date('1995-12-17T03:24:00'); var birthday = new Date(1995, 11, 17); var birthday = new Date(1995, 11, 17, 3, 24, 0); var unixTimestamp = Date.now(); // in milliseconds

Last but not least... how do I safely detect that the Date constructor does not accept the ISO 8601 format?

Proof of concept

It took me some time to figure out the best way to make it work... Actually, the most difficult part was to call the initial Date constructor with a variable number of parameters.

Download POC // Generate one Date before replacing the Date constructor var refDate = new Date(1995, 11, 17, 3, 24, 0); // from MDN // This regular expression both matches & extracts the values var _reISOString = new RegExp([ "([0-9][0-9][0-9][0-9])", "\\-", "([0-9][0-9])", "\\-", "([0-9][0-9])", "T", "([0-9][0-9])", "\\:", "([0-9][0-9])", "\\:", "([0-9][0-9])", "\\.", "([0-9][0-9][0-9])", "Z" ].join("")); // Detects ISO representation of a Date function _isISOString (value) { if ("string" !== typeof value && value.length !== 24) { return false; } _reISOString.lastIndex = 0; return _reISOString.exec(value); } // Generate the constructor call forwarder function var src = [ "var C = this,", " p = arguments,", " l = p.length;" ], args = [], idx; for (idx = 0; idx < 10; ++idx) { args.push("p[" + idx + "]"); } for (idx = 0; idx < 10; ++idx) { src.push(" if (" + idx + " === l) {"); src.push(" return new C(" + args.slice(0, idx).join(", ") + ");"); src.push(" }"); } var _genericFactory = new Function (src.join("\r\n")); function _installDate () { var globalContext = this, supported = false; // Test if ISO format supported try { var date = new Date("2003-01-22T22:45:34.075Z"); supported = 2003 === date.getUTCFullYear() && 0 === date.getUTCMonth() && 0 === date.getUTCMonth() && 22 === date.getUTCDate() && 22 === date.getUTCHours() && 45 === date.getUTCMinutes() && 34 === date.getUTCSeconds() && 75 === date.getUTCMilliseconds(); } catch (e) {} if (!supported) { // Backup original Date constructor var _oldDate = globalContext.Date; // Date override function _newDate () { var values = _isISOString(arguments[0]), idx, args; if (values) { args = []; for (idx = 0; idx < 7; ++idx) { args.push(parseInt(values[idx + 1], 10)); } // Month must be corrected (0-based) --args[1]; return new _oldDate(_oldDate.UTC.apply(_oldDate.UTC, args)); } return _genericFactory.apply(_oldDate, arguments); } // Ensure instanceof _newDate.prototype = _oldDate.prototype; // Copy methods _newDate.UTC = _oldDate.UTC; // should bind // Replace globalContext.Date = _newDate; } } // Do some tests WScript.Echo(refDate); var time = Date.UTC(2003,0,22,22,45,34,75); var utcDate = new Date(time); WScript.Echo(time + " ==> " + utcDate); _installDate(); WScript.Echo("instanceof is " + (refDate instanceof Date ? "OK" : "KO")); var isoDate = new Date("2003-01-22T22:45:34.075Z"); WScript.Echo(isoDate); var newDate = new Date(1995, 11, 17, 3, 24, 0); WScript.Echo(newDate); var newShortDate = new Date(1995, 11, 17); WScript.Echo(newShortDate); var newTimeDate = new Date(Date.UTC(1995, 11, 17, 3, 24, 0)); WScript.Echo(newTimeDate);

Using constructor result

You might notice that the new Date constructor always returns genuine Date objects. This is possible because a constructor function can return any object instead of the one allocated by new.

This is a funky feature that saved my life today !

Calling a constructor with a variable number of parameters

I tried the following approach but it fails miserably with cscript's native constructor.

Then I tried the following - ugly - approach knowing that I would have a maximum of 7 parameters. Again, cscript's Date constructor tries to use all the parameters that are passed (even if they are undefined... which leads to NaN).

function oldDateFactory (a, b, c, d, e, f, g) { return new _oldDate(a, b, c, d, e, f, g); }

So I had to use my favorite tool: code generation.

In gpf-js, code generation is used for different reasons. But this is the first time I find a proper and 'not too complex' example to illustrate the concept.

Long story short, I wanted to write a function that forwards only the right amount of parameters to the constructor. Something like:

function genericFactory () { var ClassToBuild = this, params = arguments, length = params.length; if (0 === length) { return new ClassToBuild(); } if (1 === length) { return new ClassToBuild(params[1]); } if (2 === length) { return new ClassToBuild(params[1], params[2]); } /* ... */ }

But I had to repeat the pattern for up to seven parameters... which is error prone. So I built this version (and used up to ten parameters):

var src = [ "var C = this,", " p = arguments,", " l = p.length;" ], args = [], idx; for (idx = 0; idx < 10; ++idx) { args.push("p[" + idx + "]"); } for (idx = 0; idx < 10; ++idx) { src.push(" if (" + idx + " === l) {"); src.push(" return new C(" + args.slice(0, idx).join(", ") + ");"); src.push(" }"); } var genericFactory = new Function (src.join("\r\n"));

The result is a function with the following body:

var C = this, p = arguments, l = p.length; if (0 === l) { return new C(); } if (1 === l) { return new C(p[0]); } if (2 === l) { return new C(p[0], p[1]); } if (3 === l) { return new C(p[0], p[1], p[2]); } if (4 === l) { return new C(p[0], p[1], p[2], p[3]); } if (5 === l) { return new C(p[0], p[1], p[2], p[3], p[4]); } if (6 === l) { return new C(p[0], p[1], p[2], p[3], p[4], p[5]); } if (7 === l) { return new C(p[0], p[1], p[2], p[3], p[4], p[5], p[6]); } if (8 === l) { return new C(p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7]); } if (9 === l) { return new C(p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8]); }

I would not be able to write such a function in my library because of ESLint that checks for code complexity. But, for that particular situation, it is mandatory. The advantage here is that ESLint does not see the final code.

On the other hand, it has two drawbacks:

  • This code is not considered by the coverage tool
  • Furthermore, ESLing generates an error "The Function constructor is eval" as it represents a security issue. It is true if you don't control the content of the body... which is not the case here.

instanceof

Surprisingly, setting the right prototype on the new Date constructor is enough to make instanceof work.

No comments:

Post a Comment