Skip to content

Commit

Permalink
feat(spanner): add support for interval
Browse files Browse the repository at this point in the history
  • Loading branch information
Sagar Agarwal committed Jan 16, 2025
1 parent 1be3d5d commit 1b3c462
Show file tree
Hide file tree
Showing 5 changed files with 991 additions and 1 deletion.
221 changes: 221 additions & 0 deletions src/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,212 @@ 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;
}

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', '').replace(',', '.')
);

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{3})+$/, '')}`;
}
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 +787,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 +887,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 +921,7 @@ const TypeCode: {
bytes: 'BYTES',
json: 'JSON',
jsonb: 'JSON',
interval: 'INTERVAL',
proto: 'PROTO',
enum: 'ENUM',
array: 'ARRAY',
Expand Down Expand Up @@ -745,6 +960,7 @@ interface FieldType extends Type {
* - string
* - bytes
* - json
* - interval
* - proto
* - enum
* - timestamp
Expand Down Expand Up @@ -802,6 +1018,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 +1198,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

0 comments on commit 1b3c462

Please sign in to comment.