diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..daef364 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,3 @@ +declare function parseDate(isoDate: string): Date | number | null +declare function parseDate(isoDate: null | undefined): null +export default parseDate diff --git a/index.js b/index.js index 4645a2c..a96077c 100644 --- a/index.js +++ b/index.js @@ -1,116 +1,308 @@ 'use strict' -const DATE_TIME = /(\d{1,})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d{1,})?.*?( BC)?$/ -const DATE = /^(\d{1,})-(\d{2})-(\d{2})( BC)?$/ -const TIME_ZONE = /([Z+-])(\d{2})?:?(\d{2})?:?(\d{2})?/ -const INFINITY = /^-?infinity$/ +const CHAR_CODE_0 = '0'.charCodeAt(0) +const CHAR_CODE_9 = '9'.charCodeAt(0) +const CHAR_CODE_DASH = '-'.charCodeAt(0) +const CHAR_CODE_COLON = ':'.charCodeAt(0) +const CHAR_CODE_SPACE = ' '.charCodeAt(0) +const CHAR_CODE_DOT = '.'.charCodeAt(0) +const CHAR_CODE_Z = 'Z'.charCodeAt(0) +const CHAR_CODE_MINUS = '-'.charCodeAt(0) +const CHAR_CODE_PLUS = '+'.charCodeAt(0) -module.exports = function parseDate (isoDate) { - if (INFINITY.test(isoDate)) { - // Capitalize to Infinity before passing to Number - return Number(isoDate.replace('i', 'I')) +class PGDateParser { + constructor (dateString) { + this.dateString = dateString + this.pos = 0 + this.stringLen = dateString.length } - const matches = DATE_TIME.exec(isoDate) - if (!matches) { - // Force YYYY-MM-DD dates to be parsed as local time - return getDate(isoDate) || null + isDigit (c) { + return c >= CHAR_CODE_0 && c <= CHAR_CODE_9 } - const isBC = !!matches[8] - let year = parseInt(matches[1], 10) - if (isBC) { - year = bcYearToNegativeYear(year) + /** read numbers and parse positive integer regex: \d+ */ + readInteger () { + let val = 0 + const start = this.pos + while (this.pos < this.stringLen) { + const chr = this.dateString.charCodeAt(this.pos) + if (this.isDigit(chr)) { + val = val * 10 + this.pos += 1 + val += chr - CHAR_CODE_0 + } else { + break + } + } + + if (start === this.pos) { + return null + } + + return val } - const month = parseInt(matches[2], 10) - 1 - const day = matches[3] - const hour = parseInt(matches[4], 10) - const minute = parseInt(matches[5], 10) - const second = parseInt(matches[6], 10) + /** read exactly 2 numbers and parse positive integer. regex: \d{2} */ + readInteger2 () { + const chr1 = this.dateString.charCodeAt(this.pos) + const chr2 = this.dateString.charCodeAt(this.pos + 1) - let ms = matches[7] - ms = ms ? 1000 * parseFloat(ms) : 0 + if (this.isDigit(chr1) && this.isDigit(chr2)) { + this.pos += 2 + return (chr1 - CHAR_CODE_0) * 10 + (chr2 - CHAR_CODE_0) + } - let date - const offset = timeZoneOffset(isoDate) - if (offset != null) { - date = new Date(Date.UTC(year, month, day, hour, minute, second, ms)) + return -1 + } + + skipChar (char) { + if (this.pos === this.stringLen) { + return false + } - // Account for years from 0 to 99 being interpreted as 1900-1999 - // by Date.UTC / the multi-argument form of the Date constructor - if (is0To99(year)) { - date.setUTCFullYear(year) + if (this.dateString.charCodeAt(this.pos) === char) { + this.pos += 1 + return true } - if (offset !== 0) { - date.setTime(date.getTime() - offset) + return false + } + + readBC () { + if (this.pos === this.stringLen) { + return false } - } else { - date = new Date(year, month, day, hour, minute, second, ms) - if (is0To99(year)) { - date.setFullYear(year) + if (this.dateString.slice(this.pos, this.pos + 3) === ' BC') { + this.pos += 3 + return true } + + return false } - return date -} + checkEnd () { + return this.pos === this.stringLen + } -function getDate (isoDate) { - const matches = DATE.exec(isoDate) - if (!matches) { - return + getUTC () { + return this.skipChar(CHAR_CODE_Z) } - let year = parseInt(matches[1], 10) - const isBC = !!matches[4] - if (isBC) { - year = bcYearToNegativeYear(year) + readSign () { + if (this.pos >= this.stringLen) { + return null + } + + const char = this.dateString.charCodeAt(this.pos) + if (char === CHAR_CODE_PLUS) { + this.pos += 1 + return 1 + } + + if (char === CHAR_CODE_MINUS) { + this.pos += 1 + return -1 + } + + return null } - const month = parseInt(matches[2], 10) - 1 - const day = matches[3] - // YYYY-MM-DD will be parsed as local time - const date = new Date(year, month, day) + getTZOffset () { + // special handling for '+00' at the end of - UTC + if (this.pos === this.stringLen - 3 && this.dateString.slice(this.pos, this.pos + 3) === '+00') { + this.pos += 3 + return 0 + } - if (is0To99(year)) { - date.setFullYear(year) + if (this.stringLen === this.pos) { + return undefined + } + + const sign = this.readSign() + if (sign === null) { + if (this.getUTC()) { + return 0 + } + + return undefined + } + + const hours = this.readInteger2() + if (hours === null) { + return null + } + let offset = hours * 3600 + + if (!this.skipChar(CHAR_CODE_COLON)) { + return offset * sign * 1000 + } + + const minutes = this.readInteger2() + if (minutes === null) { + return null + } + offset += minutes * 60 + + if (!this.skipChar(CHAR_CODE_COLON)) { + return offset * sign * 1000 + } + + const seconds = this.readInteger2() + if (seconds == null) { + return null + } + + return (offset + seconds) * sign * 1000 } - return date -} + /* read milliseconds out of time fraction, returns 0 if missing, null if format invalid */ + readMilliseconds () { + /* read milliseconds from fraction: .001=1, 0.1 = 100 */ + if (this.skipChar(CHAR_CODE_DOT)) { + let i = 2 + let val = 0 + const start = this.pos + while (this.pos < this.stringLen) { + const chr = this.dateString.charCodeAt(this.pos) + if (this.isDigit(chr)) { + this.pos += 1 + if (i >= 0) { + val += (chr - CHAR_CODE_0) * 10 ** i + } + i -= 1 + } else { + break + } + } + + if (start === this.pos) { + return null + } + + return val + } -// match timezones: -// Z (UTC) -// -05 -// +06:30 -function timeZoneOffset (isoDate) { - if (isoDate.endsWith('+00')) { return 0 } - const zone = TIME_ZONE.exec(isoDate.split(' ')[1]) - if (!zone) return - const type = zone[1] + readDate () { + const year = this.readInteger() + if (!this.skipChar(CHAR_CODE_DASH)) { + return null + } - if (type === 'Z') { - return 0 + let month = this.readInteger2() + if (!this.skipChar(CHAR_CODE_DASH)) { + return null + } + + const day = this.readInteger2() + if (year === null || month === null || day === null) { + return null + } + + month = month - 1 + return { year, month, day } } - const sign = type === '-' ? -1 : 1 - const offset = parseInt(zone[2], 10) * 3600 + - parseInt(zone[3] || 0, 10) * 60 + - parseInt(zone[4] || 0, 10) - return offset * sign * 1000 -} + readTime () { + if (this.stringLen - this.pos < 9 || !this.skipChar(CHAR_CODE_SPACE)) { + return { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 } + } + + const hours = this.readInteger2() + if (hours === null || !this.skipChar(CHAR_CODE_COLON)) { + return null + } + const minutes = this.readInteger2() + if (minutes === null || !this.skipChar(CHAR_CODE_COLON)) { + return null + } + const seconds = this.readInteger2() + if (seconds === null) { + return null + } + + const milliseconds = this.readMilliseconds() + if (milliseconds === null) { + return null + } + + return { hours, minutes, seconds, milliseconds } + } + + getJSDate () { + const date = this.readDate() + if (date === null) { + return null + } + + const time = this.readTime() + if (time === null) { + return null + } + + const tzOffset = this.getTZOffset() + if (tzOffset === null) { + return null + } -function bcYearToNegativeYear (year) { - // Account for numerical difference between representations of BC years - // See: https://github.com/bendrucker/postgres-date/issues/5 - return -(year - 1) + const isBC = this.readBC() + if (isBC) { + date.year = -(date.year - 1) + } + + if (!this.checkEnd()) { + return null + } + + let jsDate + if (tzOffset !== undefined) { + jsDate = new Date( + Date.UTC(date.year, date.month, date.day, time.hours, time.minutes, time.seconds, time.milliseconds) + ) + + if (date.year <= 99 && date.year >= -99) { + jsDate.setUTCFullYear(date.year) + } + + if (tzOffset !== 0) { + jsDate.setTime(jsDate.getTime() - tzOffset) + } + } else { + jsDate = new Date(date.year, date.month, date.day, time.hours, time.minutes, time.seconds, time.milliseconds) + if (date.year <= 99 && date.year >= -99) { + jsDate.setFullYear(date.year) + } + } + + return jsDate + } + + static parse (dateString) { + return new PGDateParser(dateString).getJSDate() + } } -function is0To99 (num) { - return num >= 0 && num < 100 +module.exports = function parseDate (isoDate) { + if (isoDate === null || isoDate === undefined) { + return null + } + + const date = PGDateParser.parse(isoDate) + + // parsing failed, check for infinity + if (date === null) { + if (isoDate === 'infinity') { + return Infinity + } + + if (isoDate === '-infinity') { + return -Infinity + } + } + + return date } diff --git a/index.test-d.ts b/index.test-d.ts new file mode 100644 index 0000000..fefccd4 --- /dev/null +++ b/index.test-d.ts @@ -0,0 +1,11 @@ +import { expectType, expectError } from 'tsd' + +import parse from '.' + +expectType(parse('2010-12-11 09:09:04')) +expectType(parse('infinity')) +expectType(parse('garbage')) +expectType(parse(null)) +expectType(parse(undefined)) +expectError(parse(1625042787)) +expectError(parse(new Date())) diff --git a/package.json b/package.json index 6ab3356..d2738e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-date", "main": "index.js", - "version": "1.0.7", + "version": "2.1.0", "description": "Postgres date column parser", "license": "MIT", "repository": "bendrucker/postgres-date", @@ -11,10 +11,10 @@ "url": "bendrucker.me" }, "engines": { - "node": ">=0.10.0" + "node": ">=12" }, "scripts": { - "test": "standard && tape test.js" + "test": "standard && tape test.js && tsd" }, "keywords": [ "postgres", @@ -23,11 +23,13 @@ ], "dependencies": {}, "devDependencies": { - "standard": "^16.0.0", - "tape": "^5.0.0" + "standard": "^17.0.0", + "tape": "^5.0.0", + "tsd": "^0.27.0" }, "files": [ "index.js", + "index.d.ts", "readme.md" ] } diff --git a/readme.md b/readme.md index 97eadde..4d8b825 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,7 @@ npm install --save postgres-date ## Usage ```js -var parse = require('postgres-date') +const parse = require('postgres-date') parse('2011-01-23 22:15:51Z') // => 2011-01-23T22:15:51.000Z ```