From d833bead0fc22bf7eaeb7e369d789e4262b3ee5f Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 12 Mar 2020 14:22:01 +0100 Subject: [PATCH] feat(core): duration.toHumanString() (#6691) 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. --- .../aws-dynamodb/test/test.dynamodb.ts | 14 +-- packages/@aws-cdk/core/lib/duration.ts | 105 +++++++++++++++--- packages/@aws-cdk/core/package.json | 11 -- packages/@aws-cdk/core/test/test.duration.ts | 39 ++++++- 4 files changed, 133 insertions(+), 36 deletions(-) diff --git a/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts b/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts index 83d3709d1e9a7..a833471b733ef 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts +++ b/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts @@ -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, @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', diff --git a/packages/@aws-cdk/core/lib/duration.ts b/packages/@aws-cdk/core/lib/duration.ts index 8801d6a771a1a..69152a27b5d28 100644 --- a/packages/@aws-cdk/core/lib/duration.ts +++ b/packages/@aws-cdk/core/lib/duration.ts @@ -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. */ @@ -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. */ @@ -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. */ @@ -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. */ @@ -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. */ @@ -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`. */ @@ -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 { @@ -90,6 +101,8 @@ 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 { @@ -97,6 +110,8 @@ export class Duration { } /** + * Return the total number of seconds in this Duration + * * @returns the value of this `Duration` expressed in Seconds. */ public toSeconds(opts: TimeConversionOptions = {}): number { @@ -104,6 +119,8 @@ export class Duration { } /** + * Return the total number of minutes in this Duration + * * @returns the value of this `Duration` expressed in Minutes. */ public toMinutes(opts: TimeConversionOptions = {}): number { @@ -111,6 +128,8 @@ export class Duration { } /** + * Return the total number of hours in this Duration + * * @returns the value of this `Duration` expressed in Hours. */ public toHours(opts: TimeConversionOptions = {}): number { @@ -118,6 +137,8 @@ export class Duration { } /** + * Return the total number of days in this Duration + * * @returns the value of this `Duration` expressed in Days. */ public toDays(opts: TimeConversionOptions = {}): number { @@ -125,9 +146,12 @@ export class Duration { } /** - * @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: @@ -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 ` ${this.unit.label}`; } + + let millis = convert(this.amount, this.unit, TimeUnit.Milliseconds, { integral: false }); + const parts = new Array(); + + 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 @@ -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() { @@ -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}.`); diff --git a/packages/@aws-cdk/core/package.json b/packages/@aws-cdk/core/package.json index cff11f68a3298..3e32e60453cd7 100644 --- a/packages/@aws-cdk/core/package.json +++ b/packages/@aws-cdk/core/package.json @@ -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", diff --git a/packages/@aws-cdk/core/test/test.duration.ts b/packages/@aws-cdk/core/test/test.duration.ts index 41a7a6cc110c7..81e0822b55a77 100644 --- a/packages/@aws-cdk/core/test/test.duration.ts +++ b/packages/@aws-cdk/core/test/test.duration.ts @@ -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) { @@ -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); @@ -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(), ' 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) {