Skip to content

Commit

Permalink
Memoize parsing result of date and time formats for better performance
Browse files Browse the repository at this point in the history
  • Loading branch information
sequba committed Aug 14, 2023
1 parent ca5cb70 commit a1b8ffa
Show file tree
Hide file tree
Showing 2 changed files with 96 additions and 48 deletions.
138 changes: 93 additions & 45 deletions src/DateTimeDefault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,25 @@
import {DateTime, SimpleDate, SimpleTime} from './DateTimeHelper'
import {Maybe} from './Maybe'

//const QUICK_CHECK_REGEXP = new RegExp('^[0-9/.\\-:\\s]+[ap]?m?\\s*$', '')
const QUICK_CHECK_REGEXP = new RegExp('^[0-9/.\\-: ]+[ap]?m?$', '')
export const TIME_FORMAT_SECONDS_ITEM_REGEXP = new RegExp('^ss(\.(s+|0+))?$')

Check failure

Code scanning / CodeQL

Useless regular-expression character escape High

The escape sequence '.' is equivalent to just '.', so the sequence may still represent a meta-character when it is used in a
regular expression
.

const QUICK_CHECK_REGEXP = new RegExp('^[0-9/.\\-: ]+[ap]?m?$')
const WHITESPACE_REGEXP = new RegExp('\\s+')
const DATE_SEPARATOR_REGEXP = new RegExp('[ /.-]')
const TIME_SEPARATOR = ':'
const SECONDS_PRECISION = 1000

export function defaultParseToDateTime(text: string, dateFormat?: string, timeFormat?: string): Maybe<DateTime> {
let dateTimeString = text.replace(WHITESPACE_REGEXP, ' ').trim().toLowerCase()

if (!doesItLookLikeADateTimeQuickCheck(dateTimeString)) {
export function defaultParseToDateTime(text: string, dateFormat: Maybe<string>, timeFormat: Maybe<string>): Maybe<DateTime> {
if (dateFormat === undefined && timeFormat === undefined) {
return undefined

Check warning on line 19 in src/DateTimeDefault.ts

View check run for this annotation

Codecov / codecov/patch

src/DateTimeDefault.ts#L19

Added line #L19 was not covered by tests
}

let dateTimeString = text.replace(WHITESPACE_REGEXP, ' ').trim().toLowerCase()

// if (!doesItLookLikeADateTimeQuickCheck(dateTimeString)) {
// return undefined
// }

let ampmToken: Maybe<string> = dateTimeString.substring(dateTimeString.length - 2)
if (ampmToken === 'am' || ampmToken === 'pm') {
dateTimeString = dateTimeString.substring(0, dateTimeString.length - 2).trim()
Expand Down Expand Up @@ -57,25 +63,18 @@ export function defaultParseToDateTime(text: string, dateFormat?: string, timeFo
}
}

function doesItLookLikeADateTimeQuickCheck(text: string): boolean {
return QUICK_CHECK_REGEXP.test(text)
}

export const secondsExtendedRegexp = /^ss(\.(s+|0+))?$/

function defaultParseToTime(timeItems: string[], timeFormat: Maybe<string>): Maybe<SimpleTime> {
const precision = 1000

if (timeFormat === undefined) {
return undefined
}
timeFormat = timeFormat.toLowerCase()
if (timeFormat.endsWith('am/pm')) {
timeFormat = timeFormat.substring(0, timeFormat.length - 5).trim()
} else if (timeFormat.endsWith('a/p')) {
timeFormat = timeFormat.substring(0, timeFormat.length - 3).trim()
}
const formatItems = timeFormat.split(TIME_SEPARATOR)

const {
itemsCount,
hourItem,
minuteItem,
secondItem,
} = memoizedParseTimeFormat(timeFormat)

let ampm = undefined
if (timeItems[timeItems.length - 1] === 'am' || timeItems[timeItems.length - 1] === 'a') {
ampm = false
Expand All @@ -85,15 +84,11 @@ function defaultParseToTime(timeItems: string[], timeFormat: Maybe<string>): May
timeItems.pop()
}

if (timeItems.length !== formatItems.length) {
if (timeItems.length !== itemsCount) {
return undefined
}

const hourIndex = formatItems.indexOf('hh')
const minuteIndex = formatItems.indexOf('mm')
const secondIndex = formatItems.findIndex(item => secondsExtendedRegexp.test(item))

const hourString = hourIndex !== -1 ? timeItems[hourIndex] : '0'
const hourString = hourItem !== -1 ? timeItems[hourItem] : '0'
if (!/^\d+$/.test(hourString)) {
return undefined
}
Expand All @@ -108,44 +103,58 @@ function defaultParseToTime(timeItems: string[], timeFormat: Maybe<string>): May
}
}

const minuteString = minuteIndex !== -1 ? timeItems[minuteIndex] : '0'
const minuteString = minuteItem !== -1 ? timeItems[minuteItem] : '0'
if (!/^\d+$/.test(minuteString)) {
return undefined
}
const minutes = Number(minuteString)

const secondString = secondIndex !== -1 ? timeItems[secondIndex] : '0'
const secondString = secondItem !== -1 ? timeItems[secondItem] : '0'
if (!/^\d+(\.\d+)?$/.test(secondString)) {
return undefined
}
const seconds = Math.round(Number(secondString) * SECONDS_PRECISION) / SECONDS_PRECISION

return { hours, minutes, seconds }
}

const seconds = Math.round(Number(secondString) * precision) / precision
function parseDateFormat(dateFormat: string) {
const items = dateFormat.toLowerCase().trim().split(DATE_SEPARATOR_REGEXP)

return {hours, minutes, seconds}
return {
itemsCount: items.length,
dayItem: items.indexOf('dd'),
monthItem: items.indexOf('mm'),
shortYearItem: items.indexOf('yy'),
longYearItem: items.indexOf('yyyy'),
}
}

function defaultParseToDate(dateItems: string[], dateFormat: Maybe<string>): Maybe<SimpleDate> {
if (dateFormat === undefined) {
return undefined
}
const formatItems = dateFormat.toLowerCase().split(DATE_SEPARATOR_REGEXP)
if (dateItems.length !== formatItems.length) {
const {
itemsCount,
dayItem,
monthItem,
shortYearItem,
longYearItem,
} = memoizedParseDateFormat(dateFormat)

if (dateItems.length !== itemsCount) {
return undefined
}
const monthIndex = formatItems.indexOf('mm')
const dayIndex = formatItems.indexOf('dd')
const yearIndexLong = formatItems.indexOf('yyyy')
const yearIndexShort = formatItems.indexOf('yy')
if (!(monthIndex in dateItems) || !(dayIndex in dateItems) ||
(!(yearIndexLong in dateItems) && !(yearIndexShort in dateItems))) {
if (!(monthItem in dateItems) || !(dayItem in dateItems) ||
(!(longYearItem in dateItems) && !(shortYearItem in dateItems))) {
return undefined
}
if (yearIndexLong in dateItems && yearIndexShort in dateItems) {
if (longYearItem in dateItems && shortYearItem in dateItems) {
return undefined
}
let year
if (yearIndexLong in dateItems) {
const yearString = dateItems[yearIndexLong]
if (longYearItem in dateItems) {
const yearString = dateItems[longYearItem]
if (/^\d+$/.test(yearString)) {
year = Number(yearString)
if (year < 1000 || year > 9999) {
Expand All @@ -155,7 +164,7 @@ function defaultParseToDate(dateItems: string[], dateFormat: Maybe<string>): May
return undefined
}
} else {
const yearString = dateItems[yearIndexShort]
const yearString = dateItems[shortYearItem]
if (/^\d+$/.test(yearString)) {
year = Number(yearString)
if (year < 0 || year > 99) {
Expand All @@ -165,19 +174,58 @@ function defaultParseToDate(dateItems: string[], dateFormat: Maybe<string>): May
return undefined
}
}
const monthString = dateItems[monthIndex]
const monthString = dateItems[monthItem]
if (!/^\d+$/.test(monthString)) {
return undefined
}
const month = Number(monthString)
const dayString = dateItems[dayIndex]
const dayString = dateItems[dayItem]
if (!/^\d+$/.test(dayString)) {
return undefined
}
const day = Number(dayString)
return {year, month, day}
}

function doesItLookLikeADateTimeQuickCheck(text: string): boolean {
return QUICK_CHECK_REGEXP.test(text)

Check warning on line 191 in src/DateTimeDefault.ts

View check run for this annotation

Codecov / codecov/patch

src/DateTimeDefault.ts#L190-L191

Added lines #L190 - L191 were not covered by tests
}

function parseTimeFormat(timeFormat: string): { itemsCount: number, hourItem: number, minuteItem: number, secondItem: number } {
const formatLowercase = timeFormat.toLowerCase().trim()
const formatWithoutAmPmItem = formatLowercase.endsWith('am/pm')
? formatLowercase.substring(0, formatLowercase.length - 5)

Check warning on line 197 in src/DateTimeDefault.ts

View check run for this annotation

Codecov / codecov/patch

src/DateTimeDefault.ts#L197

Added line #L197 was not covered by tests
: (formatLowercase.endsWith('a/p')
? formatLowercase.substring(0, timeFormat.length - 3)

Check warning on line 199 in src/DateTimeDefault.ts

View check run for this annotation

Codecov / codecov/patch

src/DateTimeDefault.ts#L199

Added line #L199 was not covered by tests
: formatLowercase)

const items = formatWithoutAmPmItem.trim().split(TIME_SEPARATOR)
return {
itemsCount: items.length,
hourItem: items.indexOf('hh'),
minuteItem: items.indexOf('mm'),
secondItem: items.findIndex(item => TIME_FORMAT_SECONDS_ITEM_REGEXP.test(item)),
}
}

const memoizedParseTimeFormat = memoize(parseTimeFormat)
const memoizedParseDateFormat = memoize(parseDateFormat)

function memoize<T>(fn: (arg: string) => T) {
const memoizedResults: {[key: string]: T} = {}

return (arg: string) => {
const memoizedResult = memoizedResults[arg]
if (memoizedResult !== undefined) {
return memoizedResult
}

const result = fn(arg)
memoizedResults[arg] = result
return result
}
}

// Ideas:
// - quick check -> 10% speedup
// - parse formats only once
Expand Down
6 changes: 3 additions & 3 deletions src/format/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import {Config} from '../Config'
import {secondsExtendedRegexp} from '../DateTimeDefault'
import {TIME_FORMAT_SECONDS_ITEM_REGEXP} from '../DateTimeDefault'
import {DateTimeHelper, numberToSimpleTime, SimpleDateTime, SimpleTime} from '../DateTimeHelper'
import {RawScalarValue} from '../interpreter/InterpreterValue'
import {Maybe} from '../Maybe'
Expand Down Expand Up @@ -130,7 +130,7 @@ export function defaultStringifyDuration(time: SimpleTime, formatArg: string): M
}

default: {
if (secondsExtendedRegexp.test(token.value)) {
if (TIME_FORMAT_SECONDS_ITEM_REGEXP.test(token.value)) {
const fractionOfSecondPrecision = Math.max(token.value.length - 3, 0)
result += `${time.seconds < 10 ? '0' : ''}${Math.floor(time.seconds * Math.pow(10, fractionOfSecondPrecision)) / Math.pow(10, fractionOfSecondPrecision)}`
continue
Expand Down Expand Up @@ -217,7 +217,7 @@ export function defaultStringifyDateTime(dateTime: SimpleDateTime, formatArg: st
break
}
default: {
if (secondsExtendedRegexp.test(token.value)) {
if (TIME_FORMAT_SECONDS_ITEM_REGEXP.test(token.value)) {
const fractionOfSecondPrecision = token.value.length - 3
result += `${dateTime.seconds < 10 ? '0' : ''}${Math.floor(dateTime.seconds * Math.pow(10, fractionOfSecondPrecision)) / Math.pow(10, fractionOfSecondPrecision)}`
continue
Expand Down

0 comments on commit a1b8ffa

Please sign in to comment.