Skip to content
Closed
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
48 changes: 47 additions & 1 deletion lib/result/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,48 @@ function isString(value: unknown): value is string {
return typeof value === 'string' || value instanceof String;
}

// Matches a bare (unquoted) date or timestamp literal, e.g. `2021-12-21` or `2021-12-21 21:33:59.339`
const bareDateTimeRegex = /-?\d{4,}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:?\d{2})?/y;

// The server serializes complex values (structs, maps, arrays) to JSON-like strings, but leaves
// nested DATE and TIMESTAMP values unquoted, which makes them invalid JSON. This function wraps
// such bare date/time literals (outside of string literals) in quotes so the value can be parsed.
function quoteBareDateTimes(value: string): string {
let result = '';
let i = 0;
let prevSignificant = '';
while (i < value.length) {
const ch = value[i];
if (ch === '"') {
// Copy the whole string literal as-is
let j = i + 1;
while (j < value.length && value[j] !== '"') {
j += value[j] === '\\' ? 2 : 1;
}
result += value.slice(i, j + 1);
i = j + 1;
prevSignificant = '"';
} else {
if ((ch === '-' || (ch >= '0' && ch <= '9')) && ['', ':', ',', '[', '{'].includes(prevSignificant)) {
bareDateTimeRegex.lastIndex = i;
const match = bareDateTimeRegex.exec(value);
if (match) {
result += `"${match[0]}"`;
i += match[0].length;
prevSignificant = '"';
continue; // eslint-disable-line no-continue
}
}
result += ch;
if (!/\s/.test(ch)) {
prevSignificant = ch;
}
i += 1;
}
}
return result;
}

function convertJSON(value: any, defaultValue: any): any {
if (!isString(value)) {
return value;
Expand All @@ -44,7 +86,11 @@ function convertJSON(value: any, defaultValue: any): any {
try {
return JSON.parse(value);
} catch (e) {
return defaultValue;
try {
return JSON.parse(quoteBareDateTimes(String(value)));
} catch {
return defaultValue;
}
}
}

Expand Down
83 changes: 83 additions & 0 deletions tests/e2e/complex_datetime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect } from 'chai';
import sinon from 'sinon';
import { DBSQLClient } from '../../lib';
import { ClientConfig } from '../../lib/contracts/IClientContext';
import IDBSQLSession from '../../lib/contracts/IDBSQLSession';

import config from './utils/config';

async function openSession(customConfig: Partial<ClientConfig> = {}) {
const client = new DBSQLClient();

const clientConfig = client.getConfig();
sinon.stub(client, 'getConfig').returns({
...clientConfig,
...customConfig,
});

const connection = await client.connect({
host: config.host,
path: config.path,
token: config.token,
});

return connection.openSession({
initialCatalog: config.catalog,
initialSchema: config.schema,
});
}

async function execute(session: IDBSQLSession, statement: string) {
const operation = await session.executeStatement(statement);
const result = await operation.fetchAll();
await operation.close();
return result as Array<any>;
}

const structQuery = `
SELECT
named_struct('id', 1, 'ts', TIMESTAMP'2021-12-21 21:33:59.339', 'd', DATE'2021-12-21', 's', 'x') AS st,
CAST(TIMESTAMP'2021-12-21 21:33:59.339' AS STRING) AS ts_str,
CAST(DATE'2021-12-21' AS STRING) AS d_str
`;

const collectionsQuery = `
SELECT
array(TIMESTAMP'2021-12-21 21:33:59.339', TIMESTAMP'2022-01-01 00:00:00') AS arr,
map('a', TIMESTAMP'2021-12-21 21:33:59.339') AS m,
CAST(TIMESTAMP'2021-12-21 21:33:59.339' AS STRING) AS ts1_str,
CAST(TIMESTAMP'2022-01-01 00:00:00' AS STRING) AS ts2_str
`;

describe('Complex types containing dates/timestamps (issue #103)', () => {
it('struct with nested timestamp/date is parsed (JSON results)', async () => {
const session = await openSession({ arrowEnabled: false });
try {
const [row] = await execute(session, structQuery);
expect(row.st).to.deep.equal({ id: 1, ts: row.ts_str, d: row.d_str, s: 'x' });
} finally {
await session.close();
}
});

it('array and map with nested timestamps are parsed (JSON results)', async () => {
const session = await openSession({ arrowEnabled: false });
try {
const [row] = await execute(session, collectionsQuery);
expect(row.arr).to.deep.equal([row.ts1_str, row.ts2_str]);
expect(row.m).to.deep.equal({ a: row.ts1_str });
} finally {
await session.close();
}
});

it('struct with nested timestamp/date is parsed (Arrow results without native types)', async () => {
const session = await openSession({ arrowEnabled: true, useArrowNativeTypes: false });
try {
const [row] = await execute(session, structQuery);
expect(row.st).to.deep.equal({ id: 1, ts: row.ts_str, d: row.d_str, s: 'x' });
} finally {
await session.close();
}
});
});
16 changes: 16 additions & 0 deletions tests/unit/result/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ describe('convertThriftValue', () => {
expect(result).to.equal(value);
});

it('should parse complex values containing unquoted dates/timestamps (issue #103)', () => {
const value =
'{"id":414247,"created_at":2021-12-21 21:33:59.339,"d":2021-12-21,"deleted_at":null,' +
'"s":"text 2021-12-21 with \\"quotes\\"","n":-5,"arr":[2022-01-01 00:00:00,1.5e3]}';
const result = convertThriftValue({ type: TTypeId.STRUCT_TYPE }, value);
expect(result).to.deep.equal({
id: 414247,
created_at: '2021-12-21 21:33:59.339',
d: '2021-12-21',
deleted_at: null,
s: 'text 2021-12-21 with "quotes"',
n: -5,
arr: ['2022-01-01 00:00:00', 1500],
});
});

[TTypeId.DATE_TYPE, TTypeId.TIMESTAMP_TYPE].forEach((typeId) => {
it(`should handle ${TTypeId[typeId]}`, () => {
const value = new Date();
Expand Down
Loading