diff --git a/src/bigquery.ts b/src/bigquery.ts index 58c0e441..9b1ab73c 100644 --- a/src/bigquery.ts +++ b/src/bigquery.ts @@ -191,6 +191,11 @@ export interface BigQueryDatetimeOptions { fractional?: string | number; } +export interface BigQueryRangeOptions { + start?: BigQueryDate | BigQueryDatetime | BigQueryTimestamp | string; + end?: BigQueryDate | BigQueryDatetime | BigQueryTimestamp | string; +} + export type ProvidedTypeArray = Array; export interface ProvidedTypeStruct { @@ -582,10 +587,10 @@ export class BigQuery extends Service { let value = field.v; if (schemaField.mode === 'REPEATED') { value = (value as TableRowField[]).map(val => { - return convert(schemaField, val.v, options); + return convertSchemaFieldValue(schemaField, val.v, options); }); } else { - value = convert(schemaField, value, options); + value = convertSchemaFieldValue(schemaField, value, options); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const fieldObject: any = {}; @@ -594,98 +599,6 @@ export class BigQuery extends Service { }); } - function convert( - schemaField: TableField, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any, - options: { - wrapIntegers: boolean | IntegerTypeCastOptions; - selectedFields?: string[]; - parseJSON?: boolean; - } - ) { - if (is.null(value)) { - return value; - } - - switch (schemaField.type) { - case 'BOOLEAN': - case 'BOOL': { - value = value.toLowerCase() === 'true'; - break; - } - case 'BYTES': { - value = Buffer.from(value, 'base64'); - break; - } - case 'FLOAT': - case 'FLOAT64': { - value = Number(value); - break; - } - case 'INTEGER': - case 'INT64': { - const {wrapIntegers} = options; - value = wrapIntegers - ? typeof wrapIntegers === 'object' - ? BigQuery.int( - {integerValue: value, schemaFieldName: schemaField.name}, - wrapIntegers - ).valueOf() - : BigQuery.int(value) - : Number(value); - break; - } - case 'NUMERIC': { - value = new Big(value); - break; - } - case 'BIGNUMERIC': { - value = new Big(value); - break; - } - case 'RECORD': { - value = BigQuery.mergeSchemaWithRows_( - schemaField, - value, - options - ).pop(); - break; - } - case 'DATE': { - value = BigQuery.date(value); - break; - } - case 'DATETIME': { - value = BigQuery.datetime(value); - break; - } - case 'TIME': { - value = BigQuery.time(value); - break; - } - case 'TIMESTAMP': { - const pd = new PreciseDate(); - pd.setFullTime(PreciseDate.parseFull(BigInt(value) * BigInt(1000))); - value = BigQuery.timestamp(pd); - break; - } - case 'GEOGRAPHY': { - value = BigQuery.geography(value); - break; - } - case 'JSON': { - const {parseJSON} = options; - value = parseJSON ? JSON.parse(value) : value; - break; - } - default: - break; - } - - return value; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any function flattenRows(rows: any[]) { return rows.reduce((acc, row) => { @@ -946,6 +859,47 @@ export class BigQuery extends Service { return BigQuery.timestamp(value); } + /** + * A range represents contiguous range between two dates, datetimes, or timestamps. + * The lower and upper bound for the range are optional. + * The lower bound is inclusive and the upper bound is exclusive. + * + * @method BigQuery.range + * @param {string|BigQueryRangeOptions} value The range API string or start/end with dates/datetimes/timestamp ranges. + * @param {string} elementType The range element type - DATE|DATETIME|TIMESTAMP + * + * @example + * ``` + * const {BigQuery} = require('@google-cloud/bigquery'); + * const timestampRange = BigQuery.range('[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)', 'TIMESTAMP'); + * ``` + */ + static range( + value: string | BigQueryRangeOptions, + elementType?: string + ): BigQueryRange { + return new BigQueryRange(value, elementType); + } + + /** + * A range represents contiguous range between two dates, datetimes, or timestamps. + * The lower and upper bound for the range are optional. + * The lower bound is inclusive and the upper bound is exclusive. + * + * @param {string|BigQueryRangeOptions} value The range API string or start/end with dates/datetimes/timestamp ranges. + * @param {string} elementType The range element type - DATE|DATETIME|TIMESTAMP + * + * @example + * ``` + * const {BigQuery} = require('@google-cloud/bigquery'); + * const bigquery = new BigQuery(); + * const timestampRange = bigquery.range('[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)', 'TIMESTAMP'); + * ``` + */ + range(value: string, elementType?: string): BigQueryRange { + return BigQuery.range(value, elementType); + } + /** * A BigQueryInt wraps 'INT64' values. Can be used to maintain precision. * @@ -1135,6 +1089,13 @@ export class BigQuery extends Service { typeName = 'INT64'; } else if (value instanceof Geography) { typeName = 'GEOGRAPHY'; + } else if (value instanceof BigQueryRange) { + return { + type: 'RANGE', + rangeElementType: { + type: value.elementType, + }, + }; } else if (Array.isArray(value)) { if (value.length === 0) { throw new Error( @@ -1241,6 +1202,24 @@ export class BigQuery extends Service { }, {} ); + } else if (typeName === 'RANGE') { + let rangeValue: BigQueryRange; + if (value instanceof BigQueryRange) { + rangeValue = value; + } else { + rangeValue = BigQuery.range( + value, + queryParameter.parameterType?.rangeElementType?.type + ); + } + queryParameter.parameterValue!.rangeValue = { + start: { + value: rangeValue.value.start, + }, + end: { + value: rangeValue.value.end, + }, + }; } else if (typeName === 'JSON' && is.object(value)) { queryParameter.parameterValue!.value = JSON.stringify(value); } else { @@ -1267,6 +1246,7 @@ export class BigQuery extends Service { type!.indexOf('TIME') > -1 || type!.indexOf('DATE') > -1 || type!.indexOf('GEOGRAPHY') > -1 || + type!.indexOf('RANGE') > -1 || type!.indexOf('BigQueryInt') > -1 ); } @@ -2391,9 +2371,232 @@ promisifyAll(BigQuery, { 'job', 'time', 'timestamp', + 'range', ], }); +function convertSchemaFieldValue( + schemaField: TableField, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + options: { + wrapIntegers: boolean | IntegerTypeCastOptions; + selectedFields?: string[]; + parseJSON?: boolean; + } +) { + if (is.null(value)) { + return value; + } + + switch (schemaField.type) { + case 'BOOLEAN': + case 'BOOL': { + value = value.toLowerCase() === 'true'; + break; + } + case 'BYTES': { + value = Buffer.from(value, 'base64'); + break; + } + case 'FLOAT': + case 'FLOAT64': { + value = Number(value); + break; + } + case 'INTEGER': + case 'INT64': { + const {wrapIntegers} = options; + value = wrapIntegers + ? typeof wrapIntegers === 'object' + ? BigQuery.int( + {integerValue: value, schemaFieldName: schemaField.name}, + wrapIntegers + ).valueOf() + : BigQuery.int(value) + : Number(value); + break; + } + case 'NUMERIC': { + value = new Big(value); + break; + } + case 'BIGNUMERIC': { + value = new Big(value); + break; + } + case 'RECORD': { + value = BigQuery.mergeSchemaWithRows_(schemaField, value, options).pop(); + break; + } + case 'DATE': { + value = BigQuery.date(value); + break; + } + case 'DATETIME': { + value = BigQuery.datetime(value); + break; + } + case 'TIME': { + value = BigQuery.time(value); + break; + } + case 'TIMESTAMP': { + const pd = new PreciseDate(); + pd.setFullTime(PreciseDate.parseFull(BigInt(value) * BigInt(1000))); + value = BigQuery.timestamp(pd); + break; + } + case 'GEOGRAPHY': { + value = BigQuery.geography(value); + break; + } + case 'JSON': { + const {parseJSON} = options; + value = parseJSON ? JSON.parse(value) : value; + break; + } + case 'RANGE': { + value = BigQueryRange.fromSchemaValue_( + value, + schemaField.rangeElementType!.type! + ); + break; + } + default: + break; + } + + return value; +} + +/** + * Range class for BigQuery. + * A range represents contiguous range between two dates, datetimes, or timestamps. + * The lower and upper bound for the range are optional. + * The lower bound is inclusive and the upper bound is exclusive. + * See https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#range_literals + */ +export class BigQueryRange { + elementType?: string; + start?: BigQueryTimestamp | BigQueryDate | BigQueryDatetime; + end?: BigQueryTimestamp | BigQueryDate | BigQueryDatetime; + + constructor(value: string | BigQueryRangeOptions, elementType?: string) { + if (typeof value === 'string') { + if (!elementType) { + throw new Error( + 'invalid RANGE. Element type required when using RANGE API string.' + ); + } + + const [start, end] = BigQueryRange.fromStringValue_(value); + this.start = this.convertElement_(start, elementType); + this.end = this.convertElement_(end, elementType); + this.elementType = elementType; + } else { + const {start, end} = value; + if (start && end) { + if (typeof start !== typeof end) { + throw Error( + 'upper and lower bound on a RANGE should be of the same type.' + ); + } + } + const inferredType = + { + BigQueryDate: 'DATE', + BigQueryDatetime: 'DATETIME', + BigQueryTimestamp: 'TIMESTAMP', + }[(start || end || Object).constructor.name] || elementType; + this.start = this.convertElement_(start, inferredType); + this.end = this.convertElement_(end, inferredType); + this.elementType = inferredType; + } + } + + /* + * Get Range string representation used by the BigQuery API. + */ + public get apiValue() { + return `[${this.start ? this.start.value : 'UNBOUNDED'}, ${this.end ? this.end.value : 'UNBOUNDED'})`; + } + + /* + * Get Range literal representation accordingly to + * https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#range_literals + */ + public get literalValue() { + return `RANGE<${this.elementType}> ${this.apiValue}`; + } + + public get value() { + return { + start: this.start ? this.start.value : 'UNBOUNDED', + end: this.end ? this.end.value : 'UNBOUNDED', + }; + } + + private static fromStringValue_(value: string): [start: string, end: string] { + let cleanedValue = value; + if (cleanedValue.startsWith('[') || cleanedValue.startsWith('(')) { + cleanedValue = cleanedValue.substring(1); + } + if (cleanedValue.endsWith(')') || cleanedValue.endsWith(']')) { + cleanedValue = cleanedValue.substring(0, cleanedValue.length - 1); + } + const parts = cleanedValue.split(','); + if (parts.length !== 2) { + throw new Error( + 'invalid RANGE. See RANGE literal format docs for more information.' + ); + } + + const [start, end] = parts.map((s: string) => s.trim()); + return [start, end]; + } + + static fromSchemaValue_(value: string, elementType: string): BigQueryRange { + const [start, end] = BigQueryRange.fromStringValue_(value); + const convertRangeSchemaValue = (value: string) => { + if (value === 'UNBOUNDED' || value === 'NULL') { + return null; + } + return convertSchemaFieldValue({type: elementType}, value, { + wrapIntegers: false, + }); + }; + return BigQuery.range( + { + start: convertRangeSchemaValue(start), + end: convertRangeSchemaValue(end), + }, + elementType + ); + } + + private convertElement_( + value?: string | BigQueryDate | BigQueryDatetime | BigQueryTimestamp, + elementType?: string + ) { + if (typeof value === 'string') { + if (value === 'UNBOUNDED' || value === 'NULL') { + return undefined; + } + switch (elementType) { + case 'DATE': + return new BigQueryDate(value); + case 'DATETIME': + return new BigQueryDatetime(value); + case 'TIMESTAMP': + return new BigQueryTimestamp(value); + } + return undefined; + } + return value; + } +} + /** * Date class for BigQuery. */ diff --git a/src/table.ts b/src/table.ts index 6897bebb..dd30eca2 100644 --- a/src/table.ts +++ b/src/table.ts @@ -52,7 +52,7 @@ import {GoogleErrorBody} from '@google-cloud/common/build/src/util'; import {Duplex, Writable} from 'stream'; import {JobMetadata} from './job'; import bigquery from './types'; -import {IntegerTypeCastOptions} from './bigquery'; +import {BigQueryRange, IntegerTypeCastOptions} from './bigquery'; import {RowQueue} from './rowQueue'; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -582,6 +582,7 @@ class Table extends ServiceObject { 'BigQueryInt', 'BigQueryTime', 'BigQueryTimestamp', + 'BigQueryRange', 'Geography', ]; const constructorName = value.constructor?.name; diff --git a/system-test/bigquery.ts b/system-test/bigquery.ts index fa3b21d5..0e6b9253 100644 --- a/system-test/bigquery.ts +++ b/system-test/bigquery.ts @@ -1332,6 +1332,25 @@ describe('BigQuery', () => { ); }); + it('should work with RANGE types', done => { + bigquery.query( + { + query: 'SELECT ? r', + params: [ + bigquery.range( + '[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)', + 'TIMESTAMP' + ), + ], + }, + (err, rows) => { + assert.ifError(err); + assert.strictEqual(rows!.length, 1); + done(); + } + ); + }); + it('should work with multiple types', done => { bigquery.query( { @@ -1602,6 +1621,25 @@ describe('BigQuery', () => { ); }); + it('should work with RANGE types', done => { + bigquery.query( + { + query: 'SELECT @r r', + params: { + r: bigquery.range( + '[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)', + 'TIMESTAMP' + ), + }, + }, + (err, rows) => { + assert.ifError(err); + assert.strictEqual(rows!.length, 1); + done(); + } + ); + }); + it('should work with multiple types', done => { bigquery.query( { @@ -1659,18 +1697,27 @@ describe('BigQuery', () => { const TIMESTAMP = bigquery.timestamp(new Date()); const NUMERIC = new Big('123.456'); const GEOGRAPHY = bigquery.geography('POINT(1 2)'); + const RANGE = bigquery.range( + '[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)', + 'TIMESTAMP' + ); before(() => { table = dataset.table(generateName('table')); return table.create({ schema: [ - 'date:DATE', - 'datetime:DATETIME', - 'time:TIME', - 'timestamp:TIMESTAMP', - 'numeric:NUMERIC', - 'geography:GEOGRAPHY', - ].join(', '), + {name: 'date', type: 'DATE'}, + {name: 'datetime', type: 'DATETIME'}, + {name: 'time', type: 'TIME'}, + {name: 'timestamp', type: 'TIMESTAMP'}, + {name: 'numeric', type: 'NUMERIC'}, + {name: 'geography', type: 'GEOGRAPHY'}, + { + name: 'range', + type: 'RANGE', + rangeElementType: {type: 'TIMESTAMP'}, + }, + ], }); }); @@ -1682,6 +1729,7 @@ describe('BigQuery', () => { timestamp: TIMESTAMP, numeric: NUMERIC, geography: GEOGRAPHY, + range: RANGE, }); }); }); diff --git a/test/bigquery.ts b/test/bigquery.ts index ebebb722..4d760931 100644 --- a/test/bigquery.ts +++ b/test/bigquery.ts @@ -89,6 +89,7 @@ const fakePfy = Object.assign({}, pfy, { 'job', 'time', 'timestamp', + 'range', ]); }, }); @@ -462,6 +463,14 @@ describe('BigQuery', () => { input, }; }); + + sandbox.stub(BigQuery, 'range').callsFake((input, elementType) => { + return { + type: 'fakeRange', + input, + elementType, + }; + }); }); it('should merge the schema and flatten the rows', () => { @@ -520,6 +529,7 @@ describe('BigQuery', () => { {v: 'datetime-input'}, {v: 'time-input'}, {v: 'geography-input'}, + {v: '[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)'}, ], }, expected: { @@ -562,6 +572,20 @@ describe('BigQuery', () => { input: 'geography-input', type: 'fakeGeography', }, + range: { + type: 'fakeRange', + input: { + end: { + input: '2020-12-31 12:00:00+08', + type: 'fakeDatetime', + }, + start: { + input: '2020-10-01 12:00:00+08', + type: 'fakeDatetime', + }, + }, + elementType: 'DATETIME', + }, }, }, ]; @@ -629,6 +653,14 @@ describe('BigQuery', () => { type: 'GEOGRAPHY', }); + schemaObject.fields.push({ + name: 'range', + type: 'RANGE', + rangeElementType: { + type: 'DATETIME', + }, + }); + const rawRows = rows.map(x => x.raw); const mergedRows = BigQuery.mergeSchemaWithRows_(schemaObject, rawRows, { wrapIntegers: false, @@ -942,6 +974,186 @@ describe('BigQuery', () => { }); }); + describe('range', () => { + const INPUT_DATE_RANGE = '[2020-01-01, 2020-12-31)'; + const INPUT_DATETIME_RANGE = '[2020-01-01 12:00:00, 2020-12-31 12:00:00)'; + const INPUT_TIMESTAMP_RANGE = + '[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)'; + + it('should have the correct constructor name', () => { + const range = bq.range(INPUT_DATE_RANGE, 'DATE'); + assert.strictEqual(range.constructor.name, 'BigQueryRange'); + }); + + it('should accept a string literal', () => { + const dateRange = bq.range(INPUT_DATE_RANGE, 'DATE'); + assert.strictEqual(dateRange.apiValue, '[2020-01-01, 2020-12-31)'); + assert.strictEqual( + dateRange.literalValue, + 'RANGE [2020-01-01, 2020-12-31)' + ); + assert.deepStrictEqual(dateRange.value, { + start: '2020-01-01', + end: '2020-12-31', + }); + + const datetimeRange = bq.range(INPUT_DATETIME_RANGE, 'DATETIME'); + assert.strictEqual( + datetimeRange.apiValue, + '[2020-01-01 12:00:00, 2020-12-31 12:00:00)' + ); + assert.strictEqual( + datetimeRange.literalValue, + 'RANGE [2020-01-01 12:00:00, 2020-12-31 12:00:00)' + ); + assert.deepStrictEqual(datetimeRange.value, { + start: '2020-01-01 12:00:00', + end: '2020-12-31 12:00:00', + }); + + const timestampRange = bq.range(INPUT_TIMESTAMP_RANGE, 'TIMESTAMP'); + assert.strictEqual( + timestampRange.apiValue, + '[2020-10-01T04:00:00.000Z, 2020-12-31T04:00:00.000Z)' + ); + assert.strictEqual( + timestampRange.literalValue, + 'RANGE [2020-10-01T04:00:00.000Z, 2020-12-31T04:00:00.000Z)' + ); + assert.deepStrictEqual(timestampRange.value, { + start: '2020-10-01T04:00:00.000Z', + end: '2020-12-31T04:00:00.000Z', + }); + }); + + it('should accept a BigQueryDate|BigQueryDatetime|BigQueryTimestamp objects', () => { + const dateRange = bq.range({ + start: bq.date('2020-01-01'), + end: bq.date('2020-12-31'), + }); + assert.strictEqual(dateRange.apiValue, INPUT_DATE_RANGE); + assert.strictEqual( + dateRange.literalValue, + `RANGE ${INPUT_DATE_RANGE}` + ); + assert.strictEqual(dateRange.elementType, 'DATE'); + assert.deepStrictEqual(dateRange.value, { + start: '2020-01-01', + end: '2020-12-31', + }); + + const datetimeRange = bq.range({ + start: bq.datetime('2020-01-01 12:00:00'), + end: bq.datetime('2020-12-31 12:00:00'), + }); + assert.strictEqual(datetimeRange.apiValue, INPUT_DATETIME_RANGE); + assert.strictEqual( + datetimeRange.literalValue, + `RANGE ${INPUT_DATETIME_RANGE}` + ); + assert.strictEqual(datetimeRange.elementType, 'DATETIME'); + assert.deepStrictEqual(datetimeRange.value, { + start: '2020-01-01 12:00:00', + end: '2020-12-31 12:00:00', + }); + + const timestampRange = bq.range({ + start: bq.timestamp('2020-10-01 12:00:00+08'), + end: bq.timestamp('2020-12-31 12:00:00+08'), + }); + assert.strictEqual( + timestampRange.apiValue, + '[2020-10-01T04:00:00.000Z, 2020-12-31T04:00:00.000Z)' + ); + assert.strictEqual( + timestampRange.literalValue, + 'RANGE [2020-10-01T04:00:00.000Z, 2020-12-31T04:00:00.000Z)' + ); + assert.strictEqual(timestampRange.elementType, 'TIMESTAMP'); + assert.deepStrictEqual(timestampRange.value, { + start: '2020-10-01T04:00:00.000Z', + end: '2020-12-31T04:00:00.000Z', + }); + }); + + it('should accept a start/end as string with element type', () => { + const dateRange = bq.range( + { + start: '2020-01-01', + end: '2020-12-31', + }, + 'DATE' + ); + assert.strictEqual(dateRange.apiValue, INPUT_DATE_RANGE); + assert.strictEqual( + dateRange.literalValue, + `RANGE ${INPUT_DATE_RANGE}` + ); + assert.strictEqual(dateRange.elementType, 'DATE'); + + const datetimeRange = bq.range( + { + start: '2020-01-01 12:00:00', + end: '2020-12-31 12:00:00', + }, + 'DATETIME' + ); + assert.strictEqual(datetimeRange.apiValue, INPUT_DATETIME_RANGE); + assert.strictEqual( + datetimeRange.literalValue, + `RANGE ${INPUT_DATETIME_RANGE}` + ); + assert.strictEqual(datetimeRange.elementType, 'DATETIME'); + + const timestampRange = bq.range( + { + start: '2020-10-01 12:00:00+08', + end: '2020-12-31 12:00:00+08', + }, + 'TIMESTAMP' + ); + assert.strictEqual( + timestampRange.apiValue, + '[2020-10-01T04:00:00.000Z, 2020-12-31T04:00:00.000Z)' + ); + assert.strictEqual( + timestampRange.literalValue, + 'RANGE [2020-10-01T04:00:00.000Z, 2020-12-31T04:00:00.000Z)' + ); + assert.strictEqual(timestampRange.elementType, 'TIMESTAMP'); + }); + + it('should accept a Range with start and/or end missing', () => { + const dateRange = bq.range( + { + start: '2020-01-01', + }, + 'DATE' + ); + assert.strictEqual( + dateRange.literalValue, + 'RANGE [2020-01-01, UNBOUNDED)' + ); + + const datetimeRange = bq.range( + { + end: '2020-12-31 12:00:00', + }, + 'DATETIME' + ); + assert.strictEqual( + datetimeRange.literalValue, + 'RANGE [UNBOUNDED, 2020-12-31 12:00:00)' + ); + + const timestampRange = bq.range({}, 'TIMESTAMP'); + assert.strictEqual( + timestampRange.literalValue, + 'RANGE [UNBOUNDED, UNBOUNDED)' + ); + }); + }); + describe('geography', () => { const INPUT_STRING = 'POINT(1 2)'; @@ -1216,6 +1428,15 @@ describe('BigQuery', () => { BigQuery.getTypeDescriptorFromValue_(bq.geography('POINT (1 1')).type, 'GEOGRAPHY' ); + assert.strictEqual( + BigQuery.getTypeDescriptorFromValue_( + bq.range( + '[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)', + 'TIMESTAMP' + ) + ).type, + 'RANGE' + ); }); it('should return correct type for an array', () => { @@ -1656,10 +1877,12 @@ describe('BigQuery', () => { const time = {type: 'TIME'}; const date = {type: 'DATE'}; const geo = {type: 'GEOGRAPHY'}; + const range = {type: 'RANGE'}; assert.strictEqual(BigQuery._isCustomType(time), true); assert.strictEqual(BigQuery._isCustomType(date), true); assert.strictEqual(BigQuery._isCustomType(geo), true); + assert.strictEqual(BigQuery._isCustomType(range), true); }); }); }); diff --git a/test/table.ts b/test/table.ts index 84d71ce5..47d269f8 100644 --- a/test/table.ts +++ b/test/table.ts @@ -346,6 +346,15 @@ describe('BigQuery/Table', () => { const date = new Date(); assert.strictEqual(Table.encodeValue_(date), date.toJSON()); + + const range = BigQuery.range( + '[2020-10-01 12:00:00+08, 2020-12-31 12:00:00+08)', + 'TIMESTAMP' + ); + assert.deepEqual(Table.encodeValue_(range), { + start: '2020-10-01T04:00:00.000Z', + end: '2020-12-31T04:00:00.000Z', + }); }); it('should properly encode custom types', () => {