diff --git a/src/codec.ts b/src/codec.ts index 1c49d8599..ff82ce1f2 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -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 @@ -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 => { @@ -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); } @@ -707,6 +921,7 @@ const TypeCode: { bytes: 'BYTES', json: 'JSON', jsonb: 'JSON', + interval: 'INTERVAL', proto: 'PROTO', enum: 'ENUM', array: 'ARRAY', @@ -745,6 +960,7 @@ interface FieldType extends Type { * - string * - bytes * - json + * - interval * - proto * - enum * - timestamp @@ -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}; } @@ -978,6 +1198,7 @@ export const codec = { ProtoMessage, ProtoEnum, PGOid, + Interval, convertFieldsToJson, decode, encode, diff --git a/src/index.ts b/src/index.ts index 0bbdccd01..85eb97f13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import { PGNumeric, PGJsonb, SpannerDate, + Interval, Struct, ProtoMessage, ProtoEnum, @@ -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. @@ -1892,6 +1911,7 @@ promisifyAll(Spanner, { 'pgJsonb', 'operation', 'timestamp', + 'interval', 'getInstanceAdminClient', 'getDatabaseAdminClient', ], @@ -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}; diff --git a/system-test/spanner.ts b/system-test/spanner.ts index 750ea0231..003d9da14 100644 --- a/system-test/spanner.ts +++ b/system-test/spanner.ts @@ -32,6 +32,7 @@ import { Session, protos, Float, + Interval, } from '../src'; import {Key} from '../src/table'; import { @@ -453,6 +454,7 @@ describe('Spanner', () => { before(async () => { if (IS_EMULATOR_ENABLED) { // TODO: add column Float32Value FLOAT32 and FLOAT32Array Array while using float32 feature. + // TODO: add columns using Interval Value and Interval Array Value. const [googleSqlOperationUpdateDDL] = await DATABASE.updateSchema( ` CREATE TABLE ${TABLE_NAME} @@ -480,6 +482,7 @@ describe('Spanner', () => { ); await googleSqlOperationUpdateDDL.promise(); // TODO: add column Float32Value DOUBLE PRECISION and FLOAT32Array DOUBLE PRECISION[] while using float32 feature. + // TODO: add columns using Interval Value and Interval Array Value. const [postgreSqlOperationUpdateDDL] = await PG_DATABASE.updateSchema( ` CREATE TABLE ${TABLE_NAME} @@ -510,6 +513,7 @@ describe('Spanner', () => { await postgreSqlOperationUpdateDDL.promise(); } else { // TODO: add column Float32Value FLOAT32 and FLOAT32Array Array while using float32 feature. + // TODO: add columns using Interval Value and Interval Array Value. const [googleSqlOperationUpdateDDL] = await DATABASE.updateSchema( ` CREATE TABLE ${TABLE_NAME} @@ -543,6 +547,7 @@ describe('Spanner', () => { ); await googleSqlOperationUpdateDDL.promise(); // TODO: add column Float32Value DOUBLE PRECISION and FLOAT32Array DOUBLE PRECISION[] while using float32 feature. + // TODO: add columns using Interval Value and Interval Array Value. const [postgreSqlOperationUpdateDDL] = await PG_DATABASE.updateSchema( ` CREATE TABLE ${TABLE_NAME} @@ -4009,6 +4014,7 @@ describe('Spanner', () => { before(async () => { // TODO: Add column Float32 FLOAT32 while using float32 feature. + // TODO: add columns using Interval Value and Interval Array Value when Interval is supported. const googleSqlCreateTable = await googleSqlTable.create( `CREATE TABLE ${TABLE_NAME} ( @@ -4028,6 +4034,7 @@ describe('Spanner', () => { await onPromiseOperationComplete(googleSqlCreateTable); // TODO: Add column "Float32" DOUBLE PRECISION while using float32 feature. + // TODO: add columns using Interval Value and Interval Array Value. const postgreSqlCreateTable = await postgreSqlTable.create( `CREATE TABLE ${TABLE_NAME} ( @@ -6529,6 +6536,258 @@ describe('Spanner', () => { }); }); }); + + // TODO: Enable when the interval feature has been released. + describe.skip('interval', () => { + const intervalQuery = (done, database, query, value) => { + database.run(query, (err, rows) => { + assert.ifError(err); + const queriedValue = rows[0][0].value; + assert.deepStrictEqual(queriedValue, value); + done(); + }); + }; + + it('GOOGLE_STANDARD_SQL should bind the value when param type interval is used', done => { + const query = { + sql: 'SELECT @v', + params: { + v: new Interval(19, 768, BigInt('123456789123')), + }, + types: { + v: 'interval', + }, + }; + intervalQuery( + done, + DATABASE, + query, + new Interval(19, 768, BigInt('123456789123')) + ); + }); + + it('GOOGLE_STANDARD_SQL should bind the value when spanner.interval is used', done => { + const query = { + sql: 'SELECT @v', + params: { + v: Spanner.interval(19, 768, BigInt('123456789123')), + }, + }; + intervalQuery( + done, + DATABASE, + query, + new Interval(19, 768, BigInt('123456789123')) + ); + }); + + it('POSTGRESQL should bind the value when param type interval is used', done => { + const query = { + sql: 'SELECT $1', + params: { + p1: new Interval(19, 768, BigInt('123456789123')), + }, + types: { + p1: 'interval', + }, + }; + intervalQuery( + done, + PG_DATABASE, + query, + new Interval(19, 768, BigInt('123456789123')) + ); + }); + + it('POSTGRESQL should bind the value when Spanner.interval is used', done => { + const query = { + sql: 'SELECT $1', + params: { + p1: Spanner.interval(-19, -768, BigInt('123456789123')), + }, + }; + intervalQuery( + done, + PG_DATABASE, + query, + new Interval(-19, -768, BigInt('123456789123')) + ); + }); + + it('GOOGLE_STANDARD_SQL should allow for null values', done => { + const query = { + sql: 'SELECT @v', + params: { + v: null, + }, + types: { + v: 'interval', + }, + }; + intervalQuery(done, DATABASE, query, null); + }); + + it('POSTGRESQL should allow for null values', done => { + const query = { + sql: 'SELECT $1', + params: { + p1: null, + }, + types: { + p1: 'interval', + }, + }; + intervalQuery(done, PG_DATABASE, query, null); + }); + + it('GOOGLE_STANDARD_SQL should bind arrays', done => { + const values = [ + null, + new Interval(100, 200, BigInt('123456789123')), + Interval.ZERO, + new Interval(-100, -200, BigInt('-123456789123')), + null, + ]; + const query = { + sql: 'SELECT @v', + params: { + v: values, + }, + types: { + v: { + type: 'array', + child: 'interval', + }, + }, + }; + + DATABASE.run(query, (err, rows) => { + assert.ifError(err); + const expected = values; + for (let i = 0; i < rows[0][0].value.length; i++) { + assert.deepStrictEqual(rows[0][0].value[i], expected[i]); + } + done(); + }); + }); + + it('GOOGLE_STANDARD_SQL should bind empty arrays', done => { + const values = []; + const query: ExecuteSqlRequest = { + sql: 'SELECT @v', + params: { + v: values, + }, + types: { + v: { + type: 'array', + child: 'interval', + }, + }, + }; + + DATABASE.run(query, (err, rows) => { + assert.ifError(err); + assert.deepStrictEqual(rows![0][0].value, values); + done(); + }); + }); + + it('GOOGLE_STANDARD_SQL should bind null arrays', done => { + const query: ExecuteSqlRequest = { + sql: 'SELECT @v', + params: { + v: null, + }, + types: { + v: { + type: 'array', + child: 'interval', + }, + }, + }; + + DATABASE.run(query, (err, rows) => { + assert.ifError(err); + assert.deepStrictEqual(rows![0][0].value, null); + done(); + }); + }); + + it('POSTGRESQL should bind arrays', done => { + const values = [ + null, + new Interval(100, 200, BigInt('123456789123')), + Interval.ZERO, + new Interval(-100, -200, BigInt('-123456789123')), + null, + ]; + const query = { + sql: 'SELECT $1', + params: { + p1: values, + }, + types: { + p1: { + type: 'array', + child: 'interval', + }, + }, + }; + + PG_DATABASE.run(query, (err, rows) => { + assert.ifError(err); + const expected = values; + for (let i = 0; i < rows[0][0].value.length; i++) { + assert.deepStrictEqual(rows[0][0].value[i], expected[i]); + } + done(); + }); + }); + + it('POSTGRESQL should bind empty arrays', done => { + const values = []; + const query: ExecuteSqlRequest = { + sql: 'SELECT $1', + params: { + p1: values, + }, + types: { + p1: { + type: 'array', + child: 'interval', + }, + }, + }; + + PG_DATABASE.run(query, (err, rows) => { + assert.ifError(err); + assert.deepStrictEqual(rows![0][0].value, values); + done(); + }); + }); + + it('POSTGRESQL should bind null arrays', done => { + const query: ExecuteSqlRequest = { + sql: 'SELECT $1', + params: { + p1: null, + }, + types: { + p1: { + type: 'array', + child: 'interval', + }, + }, + }; + + PG_DATABASE.run(query, (err, rows) => { + assert.ifError(err); + assert.deepStrictEqual(rows![0][0].value, null); + done(); + }); + }); + }); }); describe('large reads', () => { diff --git a/test/codec.ts b/test/codec.ts index 9a925be86..4bc5a9204 100644 --- a/test/codec.ts +++ b/test/codec.ts @@ -308,6 +308,445 @@ describe('codec', () => { }); }); + describe('Interval', () => { + it('should create an Interval instance with correct properties', () => { + const interval = new codec.Interval(1, 2, BigInt(3)); + assert.equal(interval.getMonths(), 1); + assert.equal(interval.getDays(), 2); + assert.equal(interval.getNanoseconds(), BigInt(3)); + }); + + describe('fromISO8601', () => { + it('should parse valid ISO8601 strings correctly', () => { + const testCases = [ + { + input: 'P1Y2M3DT12H12M6.789000123S', + expected: new codec.Interval(14, 3, BigInt('43926789000123')), + }, + { + input: 'P1Y2M3DT13H-48M6S', + expected: new codec.Interval(14, 3, BigInt('43926000000000')), + }, + { + input: 'P1Y2M3D', + expected: new codec.Interval(14, 3, BigInt('0')), + }, + { + input: 'P1Y2M', + expected: new codec.Interval(14, 0, BigInt('0')), + }, + { + input: 'P1Y', + expected: new codec.Interval(12, 0, BigInt('0')), + }, + { + input: 'P2M', + expected: new codec.Interval(2, 0, BigInt('0')), + }, + { + input: 'P3D', + expected: new codec.Interval(0, 3, BigInt('0')), + }, + { + input: 'PT4H25M6.7890001S', + expected: new codec.Interval(0, 0, BigInt('15906789000100')), + }, + { + input: 'PT4H25M6S', + expected: new codec.Interval(0, 0, BigInt('15906000000000')), + }, + { + input: 'PT4H30S', + expected: new codec.Interval(0, 0, BigInt('14430000000000')), + }, + { + input: 'PT4H1M', + expected: new codec.Interval(0, 0, BigInt('14460000000000')), + }, + { + input: 'PT5M', + expected: new codec.Interval(0, 0, BigInt('300000000000')), + }, + { + input: 'PT6.789S', + expected: new codec.Interval(0, 0, BigInt('6789000000')), + }, + { + input: 'PT0.123S', + expected: new codec.Interval(0, 0, BigInt('123000000')), + }, + { + input: 'PT.000000123S', + expected: new codec.Interval(0, 0, BigInt('123')), + }, + { + input: 'P0Y', + expected: new codec.Interval(0, 0, BigInt('0')), + }, + { + input: 'P-1Y-2M-3DT-12H-12M-6.789000123S', + expected: new codec.Interval(-14, -3, BigInt('-43926789000123')), + }, + { + input: 'P1Y-2M3DT13H-51M6.789S', + expected: new codec.Interval(10, 3, BigInt('43746789000000')), + }, + { + input: 'P-1Y2M-3DT-13H49M-6.789S', + expected: new codec.Interval(-10, -3, BigInt('-43866789000000')), + }, + { + input: 'P1Y2M3DT-4H25M-6.7890001S', + expected: new codec.Interval(14, 3, BigInt('-12906789000100')), + }, + { + input: 'PT100H100M100.5S', + expected: new codec.Interval(0, 0, BigInt('366100500000000')), + }, + { + input: 'P0Y', + expected: new codec.Interval(0, 0, BigInt('0')), + }, + { + input: 'PT12H30M1S', + expected: new codec.Interval(0, 0, BigInt('45001000000000')), + }, + { + input: 'P1Y2M3D', + expected: new codec.Interval(14, 3, BigInt('0')), + }, + { + input: 'P1Y2M3DT12H30M', + expected: new codec.Interval(14, 3, BigInt('45000000000000')), + }, + { + input: 'PT0.123456789S', + expected: new codec.Interval(0, 0, BigInt('123456789')), + }, + { + input: 'PT1H0.5S', + expected: new codec.Interval(0, 0, BigInt('3600500000000')), + }, + { + input: 'P1Y2M3DT12H30M1.23456789S', + expected: new codec.Interval(14, 3, BigInt('45001234567890')), + }, + { + input: 'P1Y2M3DT12H30M1,23456789S', + expected: new codec.Interval(14, 3, BigInt('45001234567890')), + }, + { + input: 'PT.5S', + expected: new codec.Interval(0, 0, BigInt('500000000')), + }, + { + input: 'P-1Y2M3DT12H-30M1.234S', + expected: new codec.Interval(-10, 3, BigInt('41401234000000')), + }, + { + input: 'P1Y-2M3DT-12H30M-1.234S', + expected: new codec.Interval(10, 3, BigInt('-41401234000000')), + }, + { + input: 'PT1.234000S', + expected: new codec.Interval(0, 0, BigInt('1234000000')), + }, + { + input: 'PT1.000S', + expected: new codec.Interval(0, 0, BigInt('1000000000')), + }, + { + input: 'PT87840000H', + expected: new codec.Interval(0, 0, BigInt('316224000000000000000')), + }, + { + input: 'PT-87840000H', + expected: new codec.Interval( + 0, + 0, + BigInt('-316224000000000000000') + ), + }, + { + input: 'P2Y1M15DT87839999H59M59.999999999S', + expected: new codec.Interval( + 25, + 15, + BigInt('316223999999999999999') + ), + }, + { + input: 'P2Y1M15DT-87839999H-59M-59.999999999S', + expected: new codec.Interval( + 25, + 15, + BigInt('-316223999999999999999') + ), + }, + ]; + + testCases.forEach(({input, expected}) => { + assert.deepStrictEqual(codec.Interval.fromISO8601(input), expected); + }); + }); + + it('should throw error for invalid ISO8601 strings', () => { + const invalidStrings = [ + 'invalid', + 'P', + 'PT', + 'P1YM', + 'P1Y2M3D4H5M6S', // Missing T + 'P1Y2M3DT4H5M6.S', // Missing decimal value + 'P1Y2M3DT4H5M6.789SS', // Extra S + 'P1Y2M3DT4H5M6.', // Missing value after decimal point + 'P1Y2M3DT4H5M6.ABC', // Non-digit characters after decimal point + 'P1Y2M3', // Missing unit specifier + 'P1Y2M3DT', // Missing time components + 'P-T1H', // Invalid negative sign position + 'PT1H-', // Invalid negative sign position + 'P1Y2M3DT4H5M6.789123456789S', // Too many digits after decimal + 'P1Y2M3DT4H5M6.123.456S', // Multiple decimal points + 'P1Y2M3DT4H5M6.,789S', // Dot and comma both for decimal + ]; + + invalidStrings.forEach(str => { + assert.throws( + () => { + codec.Interval.fromISO8601(str); + }, + new RegExp('Invalid ISO8601 duration string'), + `Expected exception on parsing ${str}` + ); + }); + }); + + it('should throw error when months is not a safe integer', () => { + // Assuming Number.MAX_SAFE_INTEGER / 12 is the max safe years + const maxSafeYears = Math.ceil(Number.MAX_SAFE_INTEGER / 12); + const invalidISOString = `P${maxSafeYears}Y4M`; + assert.throws(() => { + codec.Interval.fromISO8601(invalidISOString); + }, new RegExp('Total months is outside of the range of safe integer')); + }); + }); + + describe('toISO8601', () => { + it('should convert Interval to valid ISO8601 strings', () => { + const testCases = [ + {input: new codec.Interval(0, 0, BigInt(0)), expected: 'P0Y'}, + { + input: new codec.Interval(14, 3, BigInt(43926789000123)), + expected: 'P1Y2M3DT12H12M6.789000123S', + }, + { + input: new codec.Interval(14, 3, BigInt(14706789000000)), + expected: 'P1Y2M3DT4H5M6.789S', + }, + {input: new codec.Interval(14, 3, BigInt(0)), expected: 'P1Y2M3D'}, + {input: new codec.Interval(14, 0, BigInt(0)), expected: 'P1Y2M'}, + {input: new codec.Interval(12, 0, BigInt(0)), expected: 'P1Y'}, + {input: new codec.Interval(2, 0, BigInt(0)), expected: 'P2M'}, + {input: new codec.Interval(0, 3, BigInt(0)), expected: 'P3D'}, + { + input: new codec.Interval(0, 0, BigInt(15906789000000)), + expected: 'PT4H25M6.789S', + }, + { + input: new codec.Interval(0, 0, BigInt(14430000000000)), + expected: 'PT4H30S', + }, + { + input: new codec.Interval(0, 0, BigInt(300000000000)), + expected: 'PT5M', + }, + { + input: new codec.Interval(0, 0, BigInt(6789000000)), + expected: 'PT6.789S', + }, + { + input: new codec.Interval(0, 0, BigInt(123000000)), + expected: 'PT0.123S', + }, + { + input: new codec.Interval(0, 0, BigInt(123)), + expected: 'PT0.000000123S', + }, + { + input: new codec.Interval(0, 0, BigInt(100000000)), + expected: 'PT0.100S', + }, + { + input: new codec.Interval(0, 0, BigInt(100100000)), + expected: 'PT0.100100S', + }, + { + input: new codec.Interval(0, 0, BigInt(100100100)), + expected: 'PT0.100100100S', + }, + { + input: new codec.Interval(0, 0, BigInt(9)), + expected: 'PT0.000000009S', + }, + { + input: new codec.Interval(0, 0, BigInt(9000)), + expected: 'PT0.000009S', + }, + { + input: new codec.Interval(0, 0, BigInt(9000000)), + expected: 'PT0.009S', + }, + {input: new codec.Interval(0, 0, BigInt(0)), expected: 'P0Y'}, + {input: new codec.Interval(0, 0, BigInt(0)), expected: 'P0Y'}, + {input: new codec.Interval(1, 0, BigInt(0)), expected: 'P1M'}, + {input: new codec.Interval(0, 1, BigInt(0)), expected: 'P1D'}, + { + input: new codec.Interval(0, 0, BigInt(10010)), + expected: 'PT0.000010010S', + }, + { + input: new codec.Interval(-14, -3, BigInt(-43926789000123)), + expected: 'P-1Y-2M-3DT-12H-12M-6.789000123S', + }, + { + input: new codec.Interval(10, 3, BigInt(43746789100000)), + expected: 'P10M3DT12H9M6.789100S', + }, + { + input: new codec.Interval(-10, -3, BigInt(-43866789010000)), + expected: 'P-10M-3DT-12H-11M-6.789010S', + }, + { + input: new codec.Interval(14, 3, BigInt(-12906662400000)), + expected: 'P1Y2M3DT-3H-35M-6.662400S', + }, + { + input: new codec.Interval(0, 0, BigInt(500000000)), + expected: 'PT0.500S', + }, + { + input: new codec.Interval(0, 0, BigInt(-500000000)), + expected: 'PT-0.500S', + }, + { + input: new codec.Interval(0, 0, BigInt('316224000000000000000')), + expected: 'PT87840000H', + }, + { + input: new codec.Interval(0, 0, BigInt('-316224000000000000000')), + expected: 'PT-87840000H', + }, + { + input: new codec.Interval(25, 15, BigInt('316223999999999999999')), + expected: 'P2Y1M15DT87839999H59M59.999999999S', + }, + { + input: new codec.Interval(25, 15, BigInt('-316223999999999999999')), + expected: 'P2Y1M15DT-87839999H-59M-59.999999999S', + }, + {input: new codec.Interval(13, 0, BigInt(0)), expected: 'P1Y1M'}, + { + input: new codec.Interval(0, 0, BigInt(86400000000000)), + expected: 'PT24H', + }, + {input: new codec.Interval(0, 31, BigInt(0)), expected: 'P31D'}, + {input: new codec.Interval(-12, 0, BigInt(0)), expected: 'P-1Y'}, + ]; + + testCases.forEach(({input, expected}) => { + assert.equal(input.toISO8601(), expected); + }); + }); + }); + + it('should check equality correctly', () => { + const interval1 = new codec.Interval(1, 2, BigInt(3)); + const interval2 = new codec.Interval(1, 2, BigInt(3)); + const interval3 = new codec.Interval(-4, -5, BigInt(-6)); // Negative values + + // Test with identical intervals + assert.equal(interval1.equals(interval2), true); + assert.equal(interval2.equals(interval1), true); + + // Test with different intervals + assert.equal(interval1.equals(interval3), false); + assert.equal(interval3.equals(interval1), false); + + // Test with different values for each field (including negative) + assert.equal( + interval1.equals(new codec.Interval(1, 2, BigInt(-4))), + false + ); + assert.equal( + interval1.equals(new codec.Interval(1, -3, BigInt(3))), + false + ); + assert.equal( + interval1.equals(new codec.Interval(-2, 2, BigInt(3))), + false + ); + assert.equal( + interval3.equals(new codec.Interval(-4, -5, BigInt(6))), + false + ); + assert.equal( + interval3.equals(new codec.Interval(-4, 5, BigInt(-6))), + false + ); + assert.equal( + interval3.equals(new codec.Interval(4, -5, BigInt(-6))), + false + ); + + // Test with null and undefined + assert.equal(interval1.equals(null), false); + assert.equal(interval1.equals(undefined), false); + + // Test with an object that is not an Interval + assert.equal(interval1.equals({} as BigInt), false); + }); + + it('should return the correct value with valueOf()', () => { + const interval = new codec.Interval(1, 2, BigInt(3)); + assert.equal(interval.valueOf(), interval); + }); + + it('should return the correct JSON representation', () => { + const interval = new codec.Interval(1, 2, BigInt(3)); + const expectedJson = interval.toISO8601(); + assert.equal(interval.toJSON(), expectedJson); + }); + + describe('ISO8601 roundtrip', () => { + it('should convert Interval to ISO8601 and back without losing data', () => { + const testCases = [ + new codec.Interval(14, 3, BigInt('43926789000000')), + new codec.Interval(12, 0, BigInt(0)), + new codec.Interval(1, 0, BigInt(0)), + new codec.Interval(0, 1, BigInt(0)), + new codec.Interval(0, 0, BigInt(3600000000000)), + new codec.Interval(0, 0, BigInt(60000000000)), + new codec.Interval(0, 0, BigInt(1000000000)), + new codec.Interval(0, 0, BigInt(100000000)), + new codec.Interval(0, 0, BigInt(0)), + new codec.Interval(-10, 3, BigInt('43926000000000')), + new codec.Interval(25, 15, BigInt('86399123456789')), + new codec.Interval(-25, -15, BigInt('-86399123456789')), + new codec.Interval(13, 0, BigInt('0')), + new codec.Interval(0, 0, BigInt('86400000000000')), + new codec.Interval(0, 31, BigInt('0')), + new codec.Interval(-12, 0, BigInt('0')), + ]; + + testCases.forEach(interval => { + const isoString = interval.toISO8601(); + const roundtripInterval = codec.Interval.fromISO8601(isoString); + assert.deepStrictEqual(roundtripInterval, interval); + }); + }); + }); + }); + describe('ProtoMessage', () => { const protoMessageParams = { value: music.SingerInfo.create({ @@ -826,6 +1265,17 @@ describe('codec', () => { assert.deepStrictEqual(decoded, expected); }); + it('should decode INTERVAL', () => { + const value = 'P1Y2M-45DT67H12M6.789045638S'; + const expected = codec.Interval.fromISO8601(value); + const decoded = codec.decode(value, { + code: google.spanner.v1.TypeCode.INTERVAL, + }); + + assert(decoded instanceof codec.Interval); + assert.deepStrictEqual(decoded, expected); + }); + it('should decode ARRAY and inner members', () => { const value = ['1']; @@ -1054,6 +1504,12 @@ describe('codec', () => { assert.strictEqual(encoded, value.toJSON()); }); + it('should encode INTERVAL', () => { + const value = new codec.Interval(17, -20, BigInt(30001)); + const encoded = codec.encode(value); + assert.strictEqual(encoded, 'P1Y5M-20DT0.000030001S'); + }); + it('should encode INT64', () => { const value = new codec.Int(10); @@ -1214,6 +1670,15 @@ describe('codec', () => { assert.deepStrictEqual(codec.getType(new Date()), {type: 'timestamp'}); }); + it.skip('should determine if the value is a interval', () => { + assert.deepStrictEqual( + codec.getType(new codec.Interval(1, 2, BigInt(3))), + { + type: 'interval', + } + ); + }); + it('should determine if the value is a struct', () => { const struct = codec.Struct.fromJSON({a: 'b'}); const type = codec.getType(struct); @@ -1343,6 +1808,9 @@ describe('codec', () => { bytes: { code: google.spanner.v1.TypeCode[google.spanner.v1.TypeCode.BYTES], }, + interval: { + code: google.spanner.v1.TypeCode[google.spanner.v1.TypeCode.INTERVAL], + }, array: { code: google.spanner.v1.TypeCode[google.spanner.v1.TypeCode.ARRAY], arrayElementType: { diff --git a/test/index.ts b/test/index.ts index 9be38bb6f..af4aad806 100644 --- a/test/index.ts +++ b/test/index.ts @@ -94,6 +94,7 @@ const fakePfy = extend({}, pfy, { 'pgJsonb', 'operation', 'timestamp', + 'interval', 'getInstanceAdminClient', 'getDatabaseAdminClient', ]); @@ -641,6 +642,27 @@ describe('Spanner', () => { }); }); + describe('interval', () => { + it('should create an Interval instance', () => { + const months = 18; + const days = -25; + const nanos = BigInt('1234567891234'); + const customValue = {}; + + fakeCodec.Interval = class { + constructor(months_, days_, nanoseconds_) { + assert.strictEqual(months_, months); + assert.strictEqual(days_, days); + assert.strictEqual(nanoseconds_, nanos); + return customValue; + } + }; + + const interval = Spanner.interval(months, days, nanos); + assert.strictEqual(interval, customValue); + }); + }); + describe('protoMessage', () => { it('should create a ProtoMessage instance', () => { const protoMessageParams = {