Skip to content

Commit

Permalink
feat(core): duration.toHumanString() (#6691)
Browse files Browse the repository at this point in the history
Add a function for `Duration` to render itself to a human readable
string.

This can be used in dashboards or other situations where Durations need
to be represented.
  • Loading branch information
rix0rrr authored Mar 12, 2020
1 parent bed5357 commit d833bea
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 36 deletions.
14 changes: 7 additions & 7 deletions packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, haveResource, ResourcePart, SynthUtils } from '@aws-cdk/assert';
import * as appscaling from '@aws-cdk/aws-applicationautoscaling';
import * as iam from '@aws-cdk/aws-iam';
import { App, CfnDeletionPolicy, ConstructNode, RemovalPolicy, Stack, Tag } from '@aws-cdk/core';
import { App, CfnDeletionPolicy, ConstructNode, Duration, RemovalPolicy, Stack, Tag } from '@aws-cdk/core';
import { Test } from 'nodeunit';
import {
Attribute,
Expand Down Expand Up @@ -1142,7 +1142,7 @@ export = {

// THEN
test.deepEqual(stack.resolve(table.metricConsumedReadCapacityUnits()), {
period: { amount: 5, unit: { label: 'minutes', inSeconds: 60 } },
period: Duration.minutes(5),
dimensions: { TableName: { Ref: 'TableCD117FA1' } },
namespace: 'AWS/DynamoDB',
metricName: 'ConsumedReadCapacityUnits',
Expand All @@ -1161,7 +1161,7 @@ export = {

// THEN
test.deepEqual(stack.resolve(table.metricConsumedWriteCapacityUnits()), {
period: { amount: 5, unit: { label: 'minutes', inSeconds: 60 } },
period: Duration.minutes(5),
dimensions: { TableName: { Ref: 'TableCD117FA1' } },
namespace: 'AWS/DynamoDB',
metricName: 'ConsumedWriteCapacityUnits',
Expand All @@ -1180,7 +1180,7 @@ export = {

// THEN
test.deepEqual(stack.resolve(table.metricSystemErrors()), {
period: { amount: 5, unit: { label: 'minutes', inSeconds: 60 } },
period: Duration.minutes(5),
dimensions: { TableName: { Ref: 'TableCD117FA1' } },
namespace: 'AWS/DynamoDB',
metricName: 'SystemErrors',
Expand All @@ -1199,7 +1199,7 @@ export = {

// THEN
test.deepEqual(stack.resolve(table.metricUserErrors()), {
period: { amount: 5, unit: { label: 'minutes', inSeconds: 60 } },
period: Duration.minutes(5),
dimensions: { TableName: { Ref: 'TableCD117FA1' } },
namespace: 'AWS/DynamoDB',
metricName: 'UserErrors',
Expand All @@ -1218,7 +1218,7 @@ export = {

// THEN
test.deepEqual(stack.resolve(table.metricConditionalCheckFailedRequests()), {
period: { amount: 5, unit: { label: 'minutes', inSeconds: 60 } },
period: Duration.minutes(5),
dimensions: { TableName: { Ref: 'TableCD117FA1' } },
namespace: 'AWS/DynamoDB',
metricName: 'ConditionalCheckFailedRequests',
Expand All @@ -1237,7 +1237,7 @@ export = {

// THEN
test.deepEqual(stack.resolve(table.metricSuccessfulRequestLatency()), {
period: { amount: 5, unit: { label: 'minutes', inSeconds: 60 } },
period: Duration.minutes(5),
dimensions: { TableName: { Ref: 'TableCD117FA1' } },
namespace: 'AWS/DynamoDB',
metricName: 'SuccessfulRequestLatency',
Expand Down
105 changes: 89 additions & 16 deletions packages/@aws-cdk/core/lib/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Token } from "./token";
*/
export class Duration {
/**
* Create a Duration representing an amount of milliseconds
*
* @param amount the amount of Milliseconds the `Duration` will represent.
* @returns a new `Duration` representing `amount` ms.
*/
Expand All @@ -18,6 +20,8 @@ export class Duration {
}

/**
* Create a Duration representing an amount of seconds
*
* @param amount the amount of Seconds the `Duration` will represent.
* @returns a new `Duration` representing `amount` Seconds.
*/
Expand All @@ -26,6 +30,8 @@ export class Duration {
}

/**
* Create a Duration representing an amount of minutes
*
* @param amount the amount of Minutes the `Duration` will represent.
* @returns a new `Duration` representing `amount` Minutes.
*/
Expand All @@ -34,6 +40,8 @@ export class Duration {
}

/**
* Create a Duration representing an amount of hours
*
* @param amount the amount of Hours the `Duration` will represent.
* @returns a new `Duration` representing `amount` Hours.
*/
Expand All @@ -42,6 +50,8 @@ export class Duration {
}

/**
* Create a Duration representing an amount of days
*
* @param amount the amount of Days the `Duration` will represent.
* @returns a new `Duration` representing `amount` Days.
*/
Expand All @@ -50,8 +60,9 @@ export class Duration {
}

/**
* Parse a period formatted according to the ISO 8601 standard (see https://www.iso.org/fr/standard/70907.html).
* Parse a period formatted according to the ISO 8601 standard
*
* @see https://www.iso.org/fr/standard/70907.html
* @param duration an ISO-formtted duration to be parsed.
* @returns the parsed `Duration`.
*/
Expand All @@ -64,11 +75,11 @@ export class Duration {
if (!days && !hours && !minutes && !seconds) {
throw new Error(`Not a valid ISO duration: ${duration}`);
}
return Duration.seconds(
_toInt(seconds)
+ (_toInt(minutes) * TimeUnit.Minutes.inSeconds)
+ (_toInt(hours) * TimeUnit.Hours.inSeconds)
+ (_toInt(days) * TimeUnit.Days.inSeconds)
return Duration.millis(
_toInt(seconds) * TimeUnit.Seconds.inMillis
+ (_toInt(minutes) * TimeUnit.Minutes.inMillis)
+ (_toInt(hours) * TimeUnit.Hours.inMillis)
+ (_toInt(days) * TimeUnit.Days.inMillis)
);

function _toInt(str: string): number {
Expand All @@ -90,44 +101,57 @@ export class Duration {
}

/**
* Return the total number of milliseconds in this Duration
*
* @returns the value of this `Duration` expressed in Milliseconds.
*/
public toMilliseconds(opts: TimeConversionOptions = {}): number {
return convert(this.amount, this.unit, TimeUnit.Milliseconds, opts);
}

/**
* Return the total number of seconds in this Duration
*
* @returns the value of this `Duration` expressed in Seconds.
*/
public toSeconds(opts: TimeConversionOptions = {}): number {
return convert(this.amount, this.unit, TimeUnit.Seconds, opts);
}

/**
* Return the total number of minutes in this Duration
*
* @returns the value of this `Duration` expressed in Minutes.
*/
public toMinutes(opts: TimeConversionOptions = {}): number {
return convert(this.amount, this.unit, TimeUnit.Minutes, opts);
}

/**
* Return the total number of hours in this Duration
*
* @returns the value of this `Duration` expressed in Hours.
*/
public toHours(opts: TimeConversionOptions = {}): number {
return convert(this.amount, this.unit, TimeUnit.Hours, opts);
}

/**
* Return the total number of days in this Duration
*
* @returns the value of this `Duration` expressed in Days.
*/
public toDays(opts: TimeConversionOptions = {}): number {
return convert(this.amount, this.unit, TimeUnit.Days, opts);
}

/**
* @returns an ISO 8601 representation of this period (see https://www.iso.org/fr/standard/70907.html).
* Return an ISO 8601 representation of this period
*
* @returns a string starting with 'PT' describing the period
* @see https://www.iso.org/fr/standard/70907.html
*/
public toISOString(): string {
public toIsoString(): string {
if (this.amount === 0) { return 'PT0S'; }
switch (this.unit) {
case TimeUnit.Seconds:
Expand All @@ -143,6 +167,52 @@ export class Duration {
}
}

/**
* Return an ISO 8601 representation of this period
*
* @returns a string starting with 'PT' describing the period
* @see https://www.iso.org/fr/standard/70907.html
* @deprecated Use `toIsoString()` instead.
*/
public toISOString(): string {
return this.toIsoString();
}

/**
* Turn this duration into a human-readable string
*/
public toHumanString(): string {
if (this.amount === 0) { return fmtUnit(0, this.unit); }
if (Token.isUnresolved(this.amount)) { return `<token> ${this.unit.label}`; }

let millis = convert(this.amount, this.unit, TimeUnit.Milliseconds, { integral: false });
const parts = new Array<string>();

for (const unit of [TimeUnit.Days, TimeUnit.Hours, TimeUnit.Hours, TimeUnit.Minutes, TimeUnit.Seconds]) {
const wholeCount = Math.floor(convert(millis, TimeUnit.Milliseconds, unit, { integral: false }));
if (wholeCount > 0) {
parts.push(fmtUnit(wholeCount, unit));
millis -= wholeCount * unit.inMillis;
}
}

// Remainder in millis
if (millis > 0) {
parts.push(fmtUnit(millis, TimeUnit.Milliseconds));
}

// 2 significant parts, that's totally enough for humans
return parts.slice(0, 2).join(' ');

function fmtUnit(amount: number, unit: TimeUnit) {
if (amount === 1) {
// All of the labels end in 's'
return `${amount} ${unit.label.substring(0, unit.label.length - 1)}`;
}
return `${amount} ${unit.label}`;
}
}

/**
* Returns a string representation of this `Duration` that is also a Token that cannot be successfully resolved. This
* protects users against inadvertently stringifying a `Duration` object, when they should have called one of the
Expand Down Expand Up @@ -183,13 +253,16 @@ export interface TimeConversionOptions {
}

class TimeUnit {
public static readonly Milliseconds = new TimeUnit('millis', 0.001);
public static readonly Seconds = new TimeUnit('seconds', 1);
public static readonly Minutes = new TimeUnit('minutes', 60);
public static readonly Hours = new TimeUnit('hours', 3_600);
public static readonly Days = new TimeUnit('days', 86_400);
public static readonly Milliseconds = new TimeUnit('millis', 1);
public static readonly Seconds = new TimeUnit('seconds', 1_000);
public static readonly Minutes = new TimeUnit('minutes', 60_000);
public static readonly Hours = new TimeUnit('hours', 3_600_000);
public static readonly Days = new TimeUnit('days', 86_400_000);

private constructor(public readonly label: string, public readonly inSeconds: number) {
private constructor(public readonly label: string, public readonly inMillis: number) {
// MAX_SAFE_INTEGER is 2^53, so by representing our duration in millis (the lowest
// common unit) the highest duration we can represent is
// 2^53 / 86*10^6 ~= 104 * 10^6 days (about 100 million days).
}

public toString() {
Expand All @@ -198,8 +271,8 @@ class TimeUnit {
}

function convert(amount: number, fromUnit: TimeUnit, toUnit: TimeUnit, { integral = true }: TimeConversionOptions) {
if (fromUnit.inSeconds === toUnit.inSeconds) { return amount; }
const multiplier = fromUnit.inSeconds / toUnit.inSeconds;
if (fromUnit.inMillis === toUnit.inMillis) { return amount; }
const multiplier = fromUnit.inMillis / toUnit.inMillis;

if (Token.isUnresolved(amount)) {
throw new Error(`Unable to perform time unit conversion on un-resolved token ${amount}.`);
Expand Down
11 changes: 0 additions & 11 deletions packages/@aws-cdk/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,6 @@
"docs-public-apis:@aws-cdk/core.ConstructNode.root",
"docs-public-apis:@aws-cdk/core.ContextProvider.getKey",
"docs-public-apis:@aws-cdk/core.ContextProvider.getValue",
"docs-public-apis:@aws-cdk/core.Duration.days",
"docs-public-apis:@aws-cdk/core.Duration.hours",
"docs-public-apis:@aws-cdk/core.Duration.millis",
"docs-public-apis:@aws-cdk/core.Duration.minutes",
"docs-public-apis:@aws-cdk/core.Duration.seconds",
"docs-public-apis:@aws-cdk/core.Duration.toDays",
"docs-public-apis:@aws-cdk/core.Duration.toHours",
"docs-public-apis:@aws-cdk/core.Duration.toISOString",
"docs-public-apis:@aws-cdk/core.Duration.toMilliseconds",
"docs-public-apis:@aws-cdk/core.Duration.toMinutes",
"docs-public-apis:@aws-cdk/core.Duration.toSeconds",
"docs-public-apis:@aws-cdk/core.Lazy.anyValue",
"docs-public-apis:@aws-cdk/core.Lazy.listValue",
"docs-public-apis:@aws-cdk/core.Lazy.numberValue",
Expand Down
39 changes: 37 additions & 2 deletions packages/@aws-cdk/core/test/test.duration.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as nodeunit from 'nodeunit';
import { Duration, Stack, Token } from '../lib';
import { Duration, Lazy, Stack, Token } from '../lib';

export = nodeunit.testCase({
'negative amount'(test: nodeunit.Test) {
Expand Down Expand Up @@ -93,6 +93,22 @@ export = nodeunit.testCase({
test.done();
},

'toIsoString'(test: nodeunit.Test) {
test.equal(Duration.seconds(0).toIsoString(), 'PT0S');
test.equal(Duration.minutes(0).toIsoString(), 'PT0S');
test.equal(Duration.hours(0).toIsoString(), 'PT0S');
test.equal(Duration.days(0).toIsoString(), 'PT0S');

test.equal(Duration.seconds(5).toIsoString(), 'PT5S');
test.equal(Duration.minutes(5).toIsoString(), 'PT5M');
test.equal(Duration.hours(5).toIsoString(), 'PT5H');
test.equal(Duration.days(5).toIsoString(), 'PT5D');

test.equal(Duration.seconds(1 + 60 * (1 + 60 * (1 + 24))).toIsoString(), 'PT1D1H1M1S');

test.done();
},

'parse'(test: nodeunit.Test) {
test.equal(Duration.parse('PT0S').toSeconds(), 0);
test.equal(Duration.parse('PT0M').toSeconds(), 0);
Expand All @@ -107,7 +123,26 @@ export = nodeunit.testCase({
test.equal(Duration.parse('PT1D1H1M1S').toSeconds(), 1 + 60 * (1 + 60 * (1 + 24)));

test.done();
}
},

'to human string'(test: nodeunit.Test) {
test.equal(Duration.minutes(0).toHumanString(), '0 minutes');
test.equal(Duration.minutes(Lazy.numberValue({ produce: () => 5 })).toHumanString(), '<token> minutes');

test.equal(Duration.minutes(10).toHumanString(), '10 minutes');
test.equal(Duration.minutes(1).toHumanString(), '1 minute');

test.equal(Duration.minutes(62).toHumanString(), '1 hour 2 minutes');

test.equal(Duration.seconds(3666).toHumanString(), '1 hour 1 minute');

test.equal(Duration.millis(3000).toHumanString(), '3 seconds');
test.equal(Duration.millis(3666).toHumanString(), '3 seconds 666 millis');

test.equal(Duration.millis(3.6).toHumanString(), '3.6 millis');

test.done();
},
});

function floatEqual(test: nodeunit.Test, actual: number, expected: number) {
Expand Down

0 comments on commit d833bea

Please sign in to comment.