Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(spanner): Add support for Interval #2192

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions src/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,219 @@ export class PGOid extends WrappedNumber {
}
}

/**
* @typedef Interval
* @see Spanner.interval
*/
export class Interval {
private months: number;
private days: number;
private nanoseconds: bigint;

private static readonly ISO8601_PATTERN: RegExp =
/^P(?!$)(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(?=-?.?\d)(-?\d+H)?(-?\d+M)?(-?(((\d+)(\.\d{1,9})?)|(\.\d{1,9}))S)?)?$/;

static readonly MONTHS_PER_YEAR: number = 12;
static readonly DAYS_PER_MONTH: number = 30;
static readonly HOURS_PER_DAY: number = 24;
static readonly MINUTES_PER_HOUR: number = 60;
static readonly SECONDS_PER_MINUTE: number = 60;
static readonly SECONDS_PER_HOUR: number =
Interval.MINUTES_PER_HOUR * Interval.SECONDS_PER_MINUTE;
static readonly MILLISECONDS_PER_SECOND: number = 1000;
static readonly MICROSECONDS_PER_MILLISECOND: number = 1000;
static readonly NANOSECONDS_PER_MICROSECOND: number = 1000;
static readonly NANOSECONDS_PER_SECOND: number =
Interval.MILLISECONDS_PER_SECOND *
Interval.MICROSECONDS_PER_MILLISECOND *
Interval.NANOSECONDS_PER_MICROSECOND;
static readonly NANOSECONDS_PER_DAY: bigint =
BigInt(Interval.HOURS_PER_DAY) *
BigInt(Interval.SECONDS_PER_HOUR) *
BigInt(Interval.NANOSECONDS_PER_SECOND);
static readonly NANOSECONDS_PER_MONTH: bigint =
BigInt(Interval.DAYS_PER_MONTH) * Interval.NANOSECONDS_PER_DAY;
static readonly ZERO: Interval = new Interval(0, 0, BigInt(0));

constructor(months: number, days: number, nanos: bigint) {
if (!is.integer(months)) {
throw new GoogleError(
`Invalid months: ${months}, months should be an integral value`
);
}

if (!is.integer(days)) {
throw new GoogleError(
`Invalid days: ${days}, days should be an integral value`
);
}

this.months = months;
this.days = days;
this.nanoseconds = nanos;
}

getMonths(): number {
return this.months;
}

getDays(): number {
return this.days;
}

getNanoseconds(): bigint {
return this.nanoseconds;
}

/**
* Combines the months, days and nanoseconds part into nanoseconds.
*/
getAsNanoseconds(): bigint {
return (
BigInt(this.months) * Interval.NANOSECONDS_PER_MONTH +
BigInt(this.days) * Interval.NANOSECONDS_PER_DAY +
this.nanoseconds
);
}

static fromISO8601(isoString: string): Interval {
const matcher = Interval.ISO8601_PATTERN.exec(isoString);
if (!matcher) {
throw new GoogleError(`Invalid ISO8601 duration string: ${isoString}`);
}

const getNullOrDefault = (groupIdx: number): string =>
matcher[groupIdx] === undefined ? '0' : matcher[groupIdx];
const years: number = parseInt(getNullOrDefault(1).replace('Y', ''));
const months: number = parseInt(getNullOrDefault(2).replace('M', ''));
const days: number = parseInt(getNullOrDefault(3).replace('D', ''));
const hours: number = parseInt(getNullOrDefault(5).replace('H', ''));
const minutes: number = parseInt(getNullOrDefault(6).replace('M', ''));
const seconds: Big = Big(getNullOrDefault(7).replace('S', ''));

const totalMonths: number = Big(years)
.mul(Big(Interval.MONTHS_PER_YEAR))
.add(Big(months))
.toNumber();
if (!Number.isSafeInteger(totalMonths)) {
throw new GoogleError(
'Total months is outside of the range of safe integer'
);
}

const totalNanoseconds = BigInt(
seconds
.add(
Big((BigInt(hours) * BigInt(Interval.SECONDS_PER_HOUR)).toString())
)
.add(
Big(
(BigInt(minutes) * BigInt(Interval.SECONDS_PER_MINUTE)).toString()
)
)
.mul(Big(this.NANOSECONDS_PER_SECOND))
.toString()
);

return new Interval(totalMonths, days, totalNanoseconds);
}

toISO8601(): string {
if (this.equals(Interval.ZERO)) {
return 'P0Y';
}

let result = 'P';

if (this.months !== 0) {
const years_part: number = Math.trunc(
this.months / Interval.MONTHS_PER_YEAR
);
const months_part: number =
this.months - years_part * Interval.MONTHS_PER_YEAR;
if (years_part !== 0) {
result += `${years_part}Y`;
}
if (months_part !== 0) {
result += `${months_part}M`;
}
}

if (this.days !== 0) {
result += `${this.days}D`;
}

if (this.nanoseconds !== BigInt(0)) {
result += 'T';
let nanoseconds: bigint = this.nanoseconds;
const hours_part: bigint =
nanoseconds /
BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_HOUR);
nanoseconds =
nanoseconds -
hours_part *
BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_HOUR);

const minutes_part: bigint =
nanoseconds /
BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_MINUTE);
nanoseconds =
nanoseconds -
minutes_part *
BigInt(Interval.NANOSECONDS_PER_SECOND * Interval.SECONDS_PER_MINUTE);
const zero_bigint = BigInt(0);
if (hours_part !== zero_bigint) {
result += `${hours_part}H`;
}

if (minutes_part !== zero_bigint) {
result += `${minutes_part}M`;
}

let sign = '';

if (nanoseconds < zero_bigint) {
sign = '-';
nanoseconds = -nanoseconds;
}

const seconds_part: bigint =
nanoseconds / BigInt(Interval.NANOSECONDS_PER_SECOND);
nanoseconds =
nanoseconds - seconds_part * BigInt(Interval.NANOSECONDS_PER_SECOND);
if (seconds_part !== zero_bigint || nanoseconds !== zero_bigint) {
result += `${sign}${seconds_part}`;
if (nanoseconds !== zero_bigint) {
result += `.${nanoseconds.toString().padStart(9, '0').replace(/0+$/, '')}`;
}
result += 'S';
}
}

return result;
}

equals(other: Interval): boolean {
if (!other) {
return false;
}

return (
this.months === other.months &&
this.days === other.days &&
this.nanoseconds === other.nanoseconds
);
}

valueOf(): Interval {
return this;
}

toJSON(): string {
return this.toISO8601().toString();
}
}

/**
* @typedef JSONOptions
* @property {boolean} [wrapNumbers=false] Indicates if the numbers should be
Expand Down Expand Up @@ -581,6 +794,10 @@ function decode(
}
decoded = JSON.parse(decoded);
break;
case spannerClient.spanner.v1.TypeCode.INTERVAL:
case 'INTERVAL':
decoded = Interval.fromISO8601(decoded);
break;
case spannerClient.spanner.v1.TypeCode.ARRAY:
case 'ARRAY':
decoded = decoded.map(value => {
Expand Down Expand Up @@ -677,6 +894,10 @@ function encodeValue(value: Value): Value {
return value.toString();
}

if (value instanceof Interval) {
return value.toISO8601();
}

if (is.object(value)) {
return JSON.stringify(value);
}
Expand Down Expand Up @@ -707,6 +928,7 @@ const TypeCode: {
bytes: 'BYTES',
json: 'JSON',
jsonb: 'JSON',
interval: 'INTERVAL',
proto: 'PROTO',
enum: 'ENUM',
array: 'ARRAY',
Expand Down Expand Up @@ -745,6 +967,7 @@ interface FieldType extends Type {
* - string
* - bytes
* - json
* - interval
* - proto
* - enum
* - timestamp
Expand Down Expand Up @@ -802,6 +1025,10 @@ function getType(value: Value): Type {
return {type: 'pgOid'};
}

if (value instanceof Interval) {
return {type: 'interval'};
}

if (value instanceof ProtoMessage) {
return {type: 'proto', fullName: value.fullName};
}
Expand Down Expand Up @@ -978,6 +1205,7 @@ export const codec = {
ProtoMessage,
ProtoEnum,
PGOid,
Interval,
convertFieldsToJson,
decode,
encode,
Expand Down
22 changes: 21 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
PGNumeric,
PGJsonb,
SpannerDate,
Interval,
Struct,
ProtoMessage,
ProtoEnum,
Expand Down Expand Up @@ -1796,6 +1797,24 @@ class Spanner extends GrpcService {
return new codec.PGJsonb(value);
}

/**
* Helper function to get a Cloud Spanner Interval object.
*
* @param {number} months The months part of Interval as number.
* @param {number} days The days part of Interval as number.
* @param {bigint} nanoseconds The nanoseconds part of Interval as bigint.
* @returns {Interval}
*
* @example
* ```
* const {Spanner} = require('@google-cloud/spanner');
* const interval = Spanner.Interval(10, 20, BigInt(30));
* ```
*/
static interval(months: number, days: number, nanoseconds: bigint): Interval {
return new codec.Interval(months, days, nanoseconds);
}

/**
* @typedef IProtoMessageParams
* @property {object} value Proto Message value as serialized-buffer or message object.
Expand Down Expand Up @@ -1892,6 +1911,7 @@ promisifyAll(Spanner, {
'pgJsonb',
'operation',
'timestamp',
'interval',
'getInstanceAdminClient',
'getDatabaseAdminClient',
],
Expand Down Expand Up @@ -2061,5 +2081,5 @@ import * as protos from '../protos/protos';
import IInstanceConfig = instanceAdmin.spanner.admin.instance.v1.IInstanceConfig;
export {v1, protos};
export default {Spanner};
export {Float32, Float, Int, Struct, Numeric, PGNumeric, SpannerDate};
export {Float32, Float, Int, Struct, Numeric, PGNumeric, SpannerDate, Interval};
export {ObservabilityOptions};
Loading