├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── index.d.ts ├── index.js ├── index.test-d.ts ├── license ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: 9 | - '12' 10 | - '14' 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - run: npm install 18 | - run: npm test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare function parseDate(isoDate: string): Date | number | null 2 | declare function parseDate(isoDate: null | undefined): null 3 | export default parseDate 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const CHAR_CODE_0 = '0'.charCodeAt(0) 4 | const CHAR_CODE_9 = '9'.charCodeAt(0) 5 | const CHAR_CODE_DASH = '-'.charCodeAt(0) 6 | const CHAR_CODE_COLON = ':'.charCodeAt(0) 7 | const CHAR_CODE_SPACE = ' '.charCodeAt(0) 8 | const CHAR_CODE_DOT = '.'.charCodeAt(0) 9 | const CHAR_CODE_Z = 'Z'.charCodeAt(0) 10 | const CHAR_CODE_MINUS = '-'.charCodeAt(0) 11 | const CHAR_CODE_PLUS = '+'.charCodeAt(0) 12 | 13 | class PGDateParser { 14 | constructor (dateString) { 15 | this.dateString = dateString 16 | this.pos = 0 17 | this.stringLen = dateString.length 18 | } 19 | 20 | isDigit (c) { 21 | return c >= CHAR_CODE_0 && c <= CHAR_CODE_9 22 | } 23 | 24 | /** read numbers and parse positive integer regex: \d+ */ 25 | readInteger () { 26 | let val = 0 27 | const start = this.pos 28 | while (this.pos < this.stringLen) { 29 | const chr = this.dateString.charCodeAt(this.pos) 30 | if (this.isDigit(chr)) { 31 | val = val * 10 32 | this.pos += 1 33 | val += chr - CHAR_CODE_0 34 | } else { 35 | break 36 | } 37 | } 38 | 39 | if (start === this.pos) { 40 | return null 41 | } 42 | 43 | return val 44 | } 45 | 46 | /** read exactly 2 numbers and parse positive integer. regex: \d{2} */ 47 | readInteger2 () { 48 | const chr1 = this.dateString.charCodeAt(this.pos) 49 | const chr2 = this.dateString.charCodeAt(this.pos + 1) 50 | 51 | if (this.isDigit(chr1) && this.isDigit(chr2)) { 52 | this.pos += 2 53 | return (chr1 - CHAR_CODE_0) * 10 + (chr2 - CHAR_CODE_0) 54 | } 55 | 56 | return -1 57 | } 58 | 59 | skipChar (char) { 60 | if (this.pos === this.stringLen) { 61 | return false 62 | } 63 | 64 | if (this.dateString.charCodeAt(this.pos) === char) { 65 | this.pos += 1 66 | return true 67 | } 68 | 69 | return false 70 | } 71 | 72 | readBC () { 73 | if (this.pos === this.stringLen) { 74 | return false 75 | } 76 | 77 | if (this.dateString.slice(this.pos, this.pos + 3) === ' BC') { 78 | this.pos += 3 79 | return true 80 | } 81 | 82 | return false 83 | } 84 | 85 | checkEnd () { 86 | return this.pos === this.stringLen 87 | } 88 | 89 | getUTC () { 90 | return this.skipChar(CHAR_CODE_Z) 91 | } 92 | 93 | readSign () { 94 | if (this.pos >= this.stringLen) { 95 | return null 96 | } 97 | 98 | const char = this.dateString.charCodeAt(this.pos) 99 | if (char === CHAR_CODE_PLUS) { 100 | this.pos += 1 101 | return 1 102 | } 103 | 104 | if (char === CHAR_CODE_MINUS) { 105 | this.pos += 1 106 | return -1 107 | } 108 | 109 | return null 110 | } 111 | 112 | getTZOffset () { 113 | // special handling for '+00' at the end of - UTC 114 | if (this.pos === this.stringLen - 3 && this.dateString.slice(this.pos, this.pos + 3) === '+00') { 115 | this.pos += 3 116 | return 0 117 | } 118 | 119 | if (this.stringLen === this.pos) { 120 | return undefined 121 | } 122 | 123 | const sign = this.readSign() 124 | if (sign === null) { 125 | if (this.getUTC()) { 126 | return 0 127 | } 128 | 129 | return undefined 130 | } 131 | 132 | const hours = this.readInteger2() 133 | if (hours === null) { 134 | return null 135 | } 136 | let offset = hours * 3600 137 | 138 | if (!this.skipChar(CHAR_CODE_COLON)) { 139 | return offset * sign * 1000 140 | } 141 | 142 | const minutes = this.readInteger2() 143 | if (minutes === null) { 144 | return null 145 | } 146 | offset += minutes * 60 147 | 148 | if (!this.skipChar(CHAR_CODE_COLON)) { 149 | return offset * sign * 1000 150 | } 151 | 152 | const seconds = this.readInteger2() 153 | if (seconds == null) { 154 | return null 155 | } 156 | 157 | return (offset + seconds) * sign * 1000 158 | } 159 | 160 | /* read milliseconds out of time fraction, returns 0 if missing, null if format invalid */ 161 | readMilliseconds () { 162 | /* read milliseconds from fraction: .001=1, 0.1 = 100 */ 163 | if (this.skipChar(CHAR_CODE_DOT)) { 164 | let i = 2 165 | let val = 0 166 | const start = this.pos 167 | while (this.pos < this.stringLen) { 168 | const chr = this.dateString.charCodeAt(this.pos) 169 | if (this.isDigit(chr)) { 170 | this.pos += 1 171 | if (i >= 0) { 172 | val += (chr - CHAR_CODE_0) * 10 ** i 173 | } 174 | i -= 1 175 | } else { 176 | break 177 | } 178 | } 179 | 180 | if (start === this.pos) { 181 | return null 182 | } 183 | 184 | return val 185 | } 186 | 187 | return 0 188 | } 189 | 190 | readDate () { 191 | const year = this.readInteger() 192 | if (!this.skipChar(CHAR_CODE_DASH)) { 193 | return null 194 | } 195 | 196 | let month = this.readInteger2() 197 | if (!this.skipChar(CHAR_CODE_DASH)) { 198 | return null 199 | } 200 | 201 | const day = this.readInteger2() 202 | if (year === null || month === null || day === null) { 203 | return null 204 | } 205 | 206 | month = month - 1 207 | return { year, month, day } 208 | } 209 | 210 | readTime () { 211 | if (this.stringLen - this.pos < 9 || !this.skipChar(CHAR_CODE_SPACE)) { 212 | return { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 } 213 | } 214 | 215 | const hours = this.readInteger2() 216 | if (hours === null || !this.skipChar(CHAR_CODE_COLON)) { 217 | return null 218 | } 219 | const minutes = this.readInteger2() 220 | if (minutes === null || !this.skipChar(CHAR_CODE_COLON)) { 221 | return null 222 | } 223 | const seconds = this.readInteger2() 224 | if (seconds === null) { 225 | return null 226 | } 227 | 228 | const milliseconds = this.readMilliseconds() 229 | if (milliseconds === null) { 230 | return null 231 | } 232 | 233 | return { hours, minutes, seconds, milliseconds } 234 | } 235 | 236 | getJSDate () { 237 | const date = this.readDate() 238 | if (date === null) { 239 | return null 240 | } 241 | 242 | const time = this.readTime() 243 | if (time === null) { 244 | return null 245 | } 246 | 247 | const tzOffset = this.getTZOffset() 248 | if (tzOffset === null) { 249 | return null 250 | } 251 | 252 | const isBC = this.readBC() 253 | if (isBC) { 254 | date.year = -(date.year - 1) 255 | } 256 | 257 | if (!this.checkEnd()) { 258 | return null 259 | } 260 | 261 | let jsDate 262 | if (tzOffset !== undefined) { 263 | jsDate = new Date( 264 | Date.UTC(date.year, date.month, date.day, time.hours, time.minutes, time.seconds, time.milliseconds) 265 | ) 266 | 267 | if (date.year <= 99 && date.year >= -99) { 268 | jsDate.setUTCFullYear(date.year) 269 | } 270 | 271 | if (tzOffset !== 0) { 272 | jsDate.setTime(jsDate.getTime() - tzOffset) 273 | } 274 | } else { 275 | jsDate = new Date(date.year, date.month, date.day, time.hours, time.minutes, time.seconds, time.milliseconds) 276 | if (date.year <= 99 && date.year >= -99) { 277 | jsDate.setFullYear(date.year) 278 | } 279 | } 280 | 281 | return jsDate 282 | } 283 | 284 | static parse (dateString) { 285 | return new PGDateParser(dateString).getJSDate() 286 | } 287 | } 288 | 289 | module.exports = function parseDate (isoDate) { 290 | if (isoDate === null || isoDate === undefined) { 291 | return null 292 | } 293 | 294 | const date = PGDateParser.parse(isoDate) 295 | 296 | // parsing failed, check for infinity 297 | if (date === null) { 298 | if (isoDate === 'infinity') { 299 | return Infinity 300 | } 301 | 302 | if (isoDate === '-infinity') { 303 | return -Infinity 304 | } 305 | } 306 | 307 | return date 308 | } 309 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType, expectError } from 'tsd' 2 | 3 | import parse from '.' 4 | 5 | expectType(parse('2010-12-11 09:09:04')) 6 | expectType(parse('infinity')) 7 | expectType(parse('garbage')) 8 | expectType(parse(null)) 9 | expectType(parse(undefined)) 10 | expectError(parse(1625042787)) 11 | expectError(parse(new Date())) 12 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Ben Drucker (bendrucker.me) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postgres-date", 3 | "main": "index.js", 4 | "version": "2.1.0", 5 | "description": "Postgres date column parser", 6 | "license": "MIT", 7 | "repository": "bendrucker/postgres-date", 8 | "author": { 9 | "name": "Ben Drucker", 10 | "email": "bvdrucker@gmail.com", 11 | "url": "bendrucker.me" 12 | }, 13 | "engines": { 14 | "node": ">=12" 15 | }, 16 | "scripts": { 17 | "test": "standard && tape test.js && tsd" 18 | }, 19 | "keywords": [ 20 | "postgres", 21 | "date", 22 | "parser" 23 | ], 24 | "dependencies": {}, 25 | "devDependencies": { 26 | "standard": "^17.0.0", 27 | "tape": "^5.0.0", 28 | "tsd": "^0.27.0" 29 | }, 30 | "files": [ 31 | "index.js", 32 | "index.d.ts", 33 | "readme.md" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # postgres-date [![tests](https://github.com/bendrucker/postgres-date/workflows/tests/badge.svg)](https://github.com/bendrucker/postgres-date/actions?query=workflow%3Atests) 2 | 3 | > Postgres date output parser 4 | 5 | This package parses [date/time outputs](https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-OUTPUT) from Postgres into Javascript `Date` objects. Its goal is to match Postgres behavior and preserve data accuracy. 6 | 7 | If you find a case where a valid Postgres output results in incorrect parsing (including loss of precision), please [create a pull request](https://github.com/bendrucker/postgres-date/compare) and provide a failing test. 8 | 9 | **Supported Postgres Versions:** `>= 9.6` 10 | 11 | All prior versions of Postgres are likely compatible but not officially supported. 12 | 13 | ## Install 14 | 15 | ``` 16 | npm install --save postgres-date 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```js 22 | const parse = require('postgres-date') 23 | parse('2011-01-23 22:15:51Z') 24 | // => 2011-01-23T22:15:51.000Z 25 | ``` 26 | 27 | ## API 28 | 29 | #### `parse(isoDate)` -> `date` 30 | 31 | ##### isoDate 32 | 33 | *Required* 34 | Type: `string` 35 | 36 | A date string from Postgres. 37 | 38 | ## Releases 39 | 40 | The following semantic versioning increments will be used for changes: 41 | 42 | * **Major**: Removal of support for Node.js versions or Postgres versions (not expected) 43 | * **Minor**: Unused, since Postgres returns dates in standard ISO 8601 format 44 | * **Patch**: Any fix for parsing behavior 45 | 46 | ## License 47 | 48 | MIT © [Ben Drucker](http://bendrucker.me) 49 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tape') 4 | const parse = require('./') 5 | 6 | test('date parser', function (t) { 7 | t.equal(parse('garbage'), null) 8 | 9 | t.equal( 10 | parse('2010-12-11 09:09:04').toString(), 11 | new Date('2010-12-11 09:09:04').toString() 12 | ) 13 | 14 | t.equal( 15 | parse('2011-12-11 09:09:04 BC').toString(), 16 | new Date('-002010-12-11T09:09:04').toString() 17 | ) 18 | 19 | t.equal( 20 | parse('0001-12-11 09:09:04 BC').toString(), 21 | new Date('0000-12-11T09:09:04').toString() 22 | ) 23 | 24 | t.equal( 25 | parse('0001-12-11 BC').getFullYear(), 26 | 0 27 | ) 28 | 29 | t.equal( 30 | parse('0013-06-01').getFullYear(), 31 | 13 32 | ) 33 | 34 | t.equal( 35 | parse('1800-06-01').getFullYear(), 36 | 1800 37 | ) 38 | 39 | function ms (string) { 40 | const base = '2010-01-01 01:01:01' 41 | return parse(base + string).getMilliseconds() 42 | } 43 | t.equal(ms('.1'), 100) 44 | t.equal(ms('.01'), 10) 45 | t.equal(ms('.74'), 740) 46 | 47 | function iso (string) { 48 | return parse(string).toISOString() 49 | } 50 | 51 | t.equal( 52 | iso('2010-12-11 09:09:04.1'), 53 | new Date(2010, 11, 11, 9, 9, 4, 100).toISOString(), 54 | 'no timezones' 55 | ) 56 | 57 | t.equal( 58 | iso('2011-01-23 22:15:51.280843-06'), 59 | '2011-01-24T04:15:51.280Z', 60 | 'huge ms value' 61 | ) 62 | 63 | t.equal( 64 | iso('2011-01-23 22:15:51Z'), 65 | '2011-01-23T22:15:51.000Z', 66 | 'zulu time offset' 67 | ) 68 | 69 | t.equal( 70 | iso('2011-01-23 10:15:51-04'), 71 | '2011-01-23T14:15:51.000Z', 72 | 'negative hour offset' 73 | ) 74 | 75 | t.equal( 76 | iso('2011-01-23 10:15:51+06:10'), 77 | '2011-01-23T04:05:51.000Z', 78 | 'positive HH:mm offset' 79 | ) 80 | 81 | t.equal( 82 | iso('2011-01-23 10:15:51-06:10'), 83 | '2011-01-23T16:25:51.000Z', 84 | 'negative HH:mm offset' 85 | ) 86 | 87 | t.equal( 88 | iso('0005-02-03 10:53:28+01:53:28'), 89 | '0005-02-03T09:00:00.000Z', 90 | 'positive HH:mm:ss offset' 91 | ) 92 | 93 | t.equal( 94 | iso('0005-02-03 09:58:45-02:01:15'), 95 | '0005-02-03T12:00:00.000Z', 96 | 'negative HH:mm:ss offset' 97 | ) 98 | 99 | t.equal( 100 | iso('0076-01-01 01:30:15+12'), 101 | '0075-12-31T13:30:15.000Z', 102 | '0 to 99 year boundary' 103 | ) 104 | 105 | t.equal(parse('infinity'), Infinity) 106 | t.equal(parse('-infinity'), -Infinity) 107 | 108 | t.end() 109 | }) 110 | --------------------------------------------------------------------------------