diff --git a/connection.ts b/connection.ts index 45ccf525..73544050 100644 --- a/connection.ts +++ b/connection.ts @@ -36,7 +36,7 @@ import { parseError } from "./error.ts"; import { ConnectionParams } from "./connection_params.ts"; -enum Format { +export enum Format { TEXT = 0, BINARY = 1, } @@ -60,7 +60,7 @@ export class Message { } -class Column { +export class Column { constructor( public name: string, public tableOid: number, @@ -204,8 +204,6 @@ export class Connection { if (responseCode !== 0) { throw new Error(`Unexpected auth response code: ${responseCode}.`); } - - console.log('read auth ok!'); } private async _authCleartext() { @@ -312,7 +310,7 @@ export class Connection { // data row case "D": // this is actually packet read - const foo = this._readDataRow(msg, Format.TEXT); + const foo = this._readDataRow(msg); result.handleDataRow(foo) break; // command complete @@ -512,7 +510,7 @@ export class Connection { // data row case "D": // this is actually packet read - const rawDataRow = this._readDataRow(msg, Format.TEXT); + const rawDataRow = this._readDataRow(msg); result.handleDataRow(rawDataRow) break; // command complete @@ -563,7 +561,7 @@ export class Connection { return new RowDescription(columnCount, columns); } - _readDataRow(msg: Message, format: Format): any[] { + _readDataRow(msg: Message): any[] { const fieldCount = msg.reader.readInt16(); const row = []; @@ -575,12 +573,8 @@ export class Connection { continue; } - if (format === Format.TEXT) { - const foo = msg.reader.readString(colLength); - row.push(foo) - } else { - row.push(msg.reader.readBytes(colLength)) - } + // reading raw bytes here, they will be properly parsed later + row.push(msg.reader.readBytes(colLength)) } return row; diff --git a/decode.ts b/decode.ts new file mode 100644 index 00000000..f7212ec8 --- /dev/null +++ b/decode.ts @@ -0,0 +1,169 @@ +import { Oid } from "./oid.ts"; +import { Column, Format } from "./connection.ts"; + + +// Datetime parsing based on: +// https://github.com/bendrucker/postgres-date/blob/master/index.js +const DATETIME_RE = /^(\d{1,})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d{1,})?/; +const DATE_RE = /^(\d{1,})-(\d{2})-(\d{2})$/; +const TIMEZONE_RE = /([Z+-])(\d{2})?:?(\d{2})?:?(\d{2})?/; +const BC_RE = /BC$/; + +function decodeDate(dateStr: string): null | Date { + const matches = DATE_RE.exec(dateStr); + + if (!matches) { + return null; + } + + const year = parseInt(matches[1], 10); + // remember JS dates are 0-based + const month = parseInt(matches[2], 10) - 1; + const day = parseInt(matches[3], 10); + const date = new Date(year, month, day); + // use `setUTCFullYear` because if date is from first + // century `Date`'s compatibility for millenium bug + // would set it as 19XX + date.setUTCFullYear(year); + + return date; +} +/** + * Decode numerical timezone offset from provided date string. + * + * Matched these kinds: + * - `Z (UTC)` + * - `-05` + * - `+06:30` + * - `+06:30:10` + * + * Returns offset in miliseconds. + */ +function decodeTimezoneOffset(dateStr: string): null | number { + // get rid of date part as TIMEZONE_RE would match '-MM` part + const timeStr = dateStr.split(' ')[1]; + const matches = TIMEZONE_RE.exec(timeStr); + + if (!matches) { + return null; + } + + const type = matches[1]; + + if (type === "Z") { + // Zulu timezone === UTC === 0 + return 0; + } + + // in JS timezone offsets are reversed, ie. timezones + // that are "positive" (+01:00) are represented as negative + // offsets and vice-versa + const sign = type === '-' ? 1 : -1; + + const hours = parseInt(matches[2], 10); + const minutes = parseInt(matches[3] || "0", 10); + const seconds = parseInt(matches[4] || "0", 10); + + const offset = (hours * 3600) + (minutes * 60) + seconds; + + return sign * offset * 1000; +} + +function decodeDatetime(dateStr: string): null | number | Date { + /** + * Postgres uses ISO 8601 style date output by default: + * 1997-12-17 07:37:16-08 + */ + + // there are special `infinity` and `-infinity` + // cases representing out-of-range dates + if (dateStr === 'infinity') { + return Number(Infinity); + } else if (dateStr === "-infinity") { + return Number(-Infinity); + } + + const matches = DATETIME_RE.exec(dateStr); + + if (!matches) { + return decodeDate(dateStr); + } + + const isBC = BC_RE.test(dateStr); + + + const year = parseInt(matches[1], 10) * (isBC ? -1 : 1); + // remember JS dates are 0-based + const month = parseInt(matches[2], 10) - 1; + const day = parseInt(matches[3], 10); + const hour = parseInt(matches[4], 10); + const minute = parseInt(matches[5], 10); + const second = parseInt(matches[6], 10); + // ms are written as .007 + const msMatch = matches[7]; + const ms = msMatch ? 1000 * parseFloat(msMatch) : 0; + + + let date: Date; + + const offset = decodeTimezoneOffset(dateStr); + if (offset === null) { + date = new Date(year, month, day, hour, minute, second, ms); + } else { + // This returns miliseconds from 1 January, 1970, 00:00:00, + // adding decoded timezone offset will construct proper date object. + const utc = Date.UTC(year, month, day, hour, minute, second, ms); + date = new Date(utc + offset); + } + + // use `setUTCFullYear` because if date is from first + // century `Date`'s compatibility for millenium bug + // would set it as 19XX + date.setUTCFullYear(year); + return date; +} + +function decodeBinary() { + throw new Error("Not implemented!") +} + +const decoder = new TextDecoder(); + +function decodeText(value: Uint8Array, typeOid: number): any { + const strValue = decoder.decode(value); + + switch (typeOid) { + case Oid.char: + case Oid.varchar: + case Oid.text: + case Oid.time: + case Oid.timetz: + return strValue; + case Oid.bool: + return strValue[0] === "t"; + case Oid.int2: + case Oid.int4: + case Oid.int8: + return parseInt(strValue, 10); + case Oid.float4: + case Oid.float8: + return parseFloat(strValue); + case Oid.timestamptz: + case Oid.timestamp: + return decodeDatetime(strValue); + case Oid.date: + return decodeDate(strValue); + default: + throw new Error(`Don't know how to parse column type: ${typeOid}`); + } +} + +export function decode(value: Uint8Array, column: Column) { + if (column.format === Format.BINARY) { + return decodeBinary(); + } else if (column.format === Format.TEXT) { + return decodeText(value, column.typeOid); + } else { + throw new Error(`Unknown column format: ${column.format}`); + } +} diff --git a/oid.ts b/oid.ts new file mode 100644 index 00000000..67693ca6 --- /dev/null +++ b/oid.ts @@ -0,0 +1,169 @@ +export const Oid = { + bool: 16, + bytea: 17, + char: 18, + name: 19, + int8: 20, + int2: 21, + int2vector: 22, + int4: 23, + regproc: 24, + text: 25, + oid: 26, + tid: 27, + xid: 28, + cid: 29, + oidvector: 30, + pg_ddl_command: 32, + pg_type: 71, + pg_attribute: 75, + pg_proc: 81, + pg_class: 83, + json: 114, + xml: 142, + _xml: 143, + pg_node_tree: 194, + _json: 199, + smgr: 210, + index_am_handler: 325, + point: 600, + lseg: 601, + path: 602, + box: 603, + polygon: 604, + line: 628, + _line: 629, + cidr: 650, + _cidr: 651, + float4: 700, + float8: 701, + abstime: 702, + reltime: 703, + tinterval: 704, + unknown: 705, + circle: 718, + _circle: 719, + money: 790, + _money: 791, + macaddr: 829, + inet: 869, + _bool: 1000, + _bytea: 1001, + _char: 1002, + _name: 1003, + _int2: 1005, + _int2vector: 1006, + _int4: 1007, + _regproc: 1008, + _text: 1009, + _tid: 1010, + _xid: 1011, + _cid: 1012, + _oidvector: 1013, + _bpchar: 1014, + _varchar: 1015, + _int8: 1016, + _point: 1017, + _lseg: 1018, + _path: 1019, + _box: 1020, + _float4: 1021, + _float8: 1022, + _abstime: 1023, + _reltime: 1024, + _tinterval: 1025, + _polygon: 1027, + _oid: 1028, + aclitem: 1033, + _aclitem: 1034, + _macaddr: 1040, + _inet: 1041, + bpchar: 1042, + varchar: 1043, + date: 1082, + time: 1083, + timestamp: 1114, + _timestamp: 1115, + _date: 1182, + _time: 1183, + timestamptz: 1184, + _timestamptz: 1185, + interval: 1186, + _interval: 1187, + _numeric: 1231, + pg_database: 1248, + _cstring: 1263, + timetz: 1266, + _timetz: 1270, + bit: 1560, + _bit: 1561, + varbit: 1562, + _varbit: 1563, + numeric: 1700, + refcursor: 1790, + _refcursor: 2201, + regprocedure: 2202, + regoper: 2203, + regoperator: 2204, + regclass: 2205, + regtype: 2206, + _regprocedure: 2207, + _regoper: 2208, + _regoperator: 2209, + _regclass: 2210, + _regtype: 2211, + record: 2249, + cstring: 2275, + any: 2276, + anyarray: 2277, + void: 2278, + trigger: 2279, + language_handler: 2280, + internal: 2281, + opaque: 2282, + anyelement: 2283, + _record: 2287, + anynonarray: 2776, + pg_authid: 2842, + pg_auth_members: 2843, + _txid_snapshot: 2949, + uuid: 2950, + _uuid: 2951, + txid_snapshot: 2970, + fdw_handler: 3115, + pg_lsn: 3220, + _pg_lsn: 3221, + tsm_handler: 3310, + anyenum: 3500, + tsvector: 3614, + tsquery: 3615, + gtsvector: 3642, + _tsvector: 3643, + _gtsvector: 3644, + _tsquery: 3645, + regconfig: 3734, + _regconfig: 3735, + regdictionary: 3769, + _regdictionary: 3770, + jsonb: 3802, + _jsonb: 3807, + anyrange: 3831, + event_trigger: 3838, + int4range: 3904, + _int4range: 3905, + numrange: 3906, + _numrange: 3907, + tsrange: 3908, + _tsrange: 3909, + tstzrange: 3910, + _tstzrange: 3911, + daterange: 3912, + _daterange: 3913, + int8range: 3926, + _int8range: 3927, + pg_shseclabel: 4066, + regnamespace: 4089, + _regnamespace: 4090, + regrole: 4096, + _regrole: 4097, +} \ No newline at end of file diff --git a/query.ts b/query.ts index 799aefba..c2bfb1e3 100644 --- a/query.ts +++ b/query.ts @@ -1,7 +1,9 @@ -import { RowDescription } from "./connection.ts"; +import { RowDescription, Column, Format } from "./connection.ts"; import { Connection } from "./connection.ts"; import { encode, EncodedArg } from "./encode.ts"; +import { decode } from "./decode.ts"; + export interface QueryConfig { text: string; args?: any[]; @@ -14,6 +16,8 @@ export class QueryResult { private _done = false; public rows: any[] = []; // actual results + constructor(public query: Query) {} + handleRowDescription(description: RowDescription) { this.rowDescription = description; } @@ -22,13 +26,13 @@ export class QueryResult { const parsedRow = []; for (let i = 0, len = dataRow.length; i < len; i++) { + const column = this.rowDescription.columns[i]; const rawValue = dataRow[i]; + if (rawValue === null) { parsedRow.push(null); } else { - // TODO: parse properly - const parsedValue = rawValue; - parsedRow.push(parsedValue) + parsedRow.push(decode(rawValue, column)) } } @@ -68,7 +72,7 @@ export class Query { constructor(public connection: Connection, config: QueryConfig) { this.text = config.text; this.args = this._prepareArgs(config); - this.result = new QueryResult(); + this.result = new QueryResult(this); } private _prepareArgs(config: QueryConfig): EncodedArg[] { diff --git a/tests/queries.ts b/tests/queries.ts index 227ef3d3..aebb5390 100644 --- a/tests/queries.ts +++ b/tests/queries.ts @@ -27,11 +27,12 @@ test(async function beforeEach() { await client.query("DROP TABLE IF EXISTS ids;"); await client.query("CREATE TABLE ids(id integer);"); - await client.query("INSERT INTO ids(id) values(1);"); - await client.query("INSERT INTO ids(id) values(2);"); + await client.query("INSERT INTO ids(id) VALUES(1);"); + await client.query("INSERT INTO ids(id) VALUES(2);"); await client.query("DROP TABLE IF EXISTS timestamps;"); - await client.query("CREATE TABLE timestamps(dt timestamp);"); + await client.query("CREATE TABLE timestamps(dt timestamptz);"); + await client.query(`INSERT INTO timestamps(dt) VALUES('2019-02-10T10:30:40.005+04:30');`); }); @@ -48,14 +49,28 @@ test(async function parametrizedQuery() { const result = await client.query('SELECT * FROM ids WHERE id < $1;', 2); assertEqual(result.rows.length, 1); + + const objectRows = result.rowsOfObjects(); + const row = objectRows[0]; + + assertEqual(row.id, 1); + assertEqual(typeof row.id, "number"); }); -// TODO: make this test work - wrong message receiving logic test(async function nativeType() { const client = await getTestClient(); - const result = await client.query('INSERT INTO timestamps(dt) values($1);', new Date()); - console.log(result.rows); + const result = await client.query("SELECT * FROM timestamps;"); + const row = result.rows[0]; + + const expectedDate = Date.UTC(2019, 1, 10, 6, 0, 40, 5); + + assertEqual( + row[0].toUTCString(), + new Date(expectedDate).toUTCString() + ) + + await client.query('INSERT INTO timestamps(dt) values($1);', new Date()); }); test(async function tearDown() {