Skip to content

Commit

Permalink
Merge pull request #1368 from concord-consortium/187799270-date-formu…
Browse files Browse the repository at this point in the history
…las-2

Implement most of date functions, extract createDate/ and convertToDate, add tests
  • Loading branch information
pjanik authored Jul 23, 2024
2 parents f68ac28 + e2e675a commit 9f88e7d
Show file tree
Hide file tree
Showing 8 changed files with 465 additions and 150 deletions.
152 changes: 152 additions & 0 deletions v3/src/models/formula/functions/date-functions.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatDate } from "../../../utilities/date-utils"
import { UNDEF_RESULT } from "./function-utils"
import { math } from "./math"

describe("date", () => {
Expand Down Expand Up @@ -55,3 +56,154 @@ describe("date", () => {
expect(fn2.evaluate()).toEqual(formatDate(new Date(2020, 4, 1)))
})
})

describe("year", () => {
it("returns the year of the provided date object", () => {
const fn = math.compile("year(date(2020, 2, 3))")
expect(fn.evaluate()).toEqual(2020)
})

it("returns the year of the provided date string", () => {
const fn = math.compile("year('2020-02-03')")
expect(fn.evaluate()).toEqual(2020)
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("year(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("month", () => {
it("returns the month of the provided date object", () => {
const fn = math.compile("month(date(2020, 2, 3))")
expect(fn.evaluate()).toEqual(2)
})

it("returns the month of the provided date string", () => {
const fn = math.compile("month('2020-02-03')")
expect(fn.evaluate()).toEqual(2)
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("month(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("monthName", () => {
it("returns the month name of the provided date object", () => {
const fn = math.compile("monthName(date(2020, 2, 3))")
expect(fn.evaluate()).toEqual("February")
})

it("returns the month name of the provided date string", () => {
const fn = math.compile("monthName('2020-02-03')")
expect(fn.evaluate()).toEqual("February")
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("monthName(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("dayOfMonth", () => {
it("returns the day of the month of the provided date object", () => {
const fn = math.compile("dayOfMonth(date(2020, 2, 3))")
expect(fn.evaluate()).toEqual(3)
})

it("returns the day of the month of the provided date string", () => {
const fn = math.compile("dayOfMonth('2020-02-03')")
expect(fn.evaluate()).toEqual(3)
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("dayOfMonth(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("dayOfWeek", () => {
it("returns the day of the week of the provided date object", () => {
const fn = math.compile("dayOfWeek(date(2020, 2, 3))")
expect(fn.evaluate()).toEqual(2) // Sunday is 1, Monday is 2
})

it("returns the day of the week of the provided date string", () => {
const fn = math.compile("dayOfWeek('2020-02-03')")
expect(fn.evaluate()).toEqual(2) // Sunday is 1, Monday is 2
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("dayOfWeek(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("dayOfWeekName", () => {
it("returns the day of the week name of the provided date object", () => {
const fn = math.compile("dayOfWeekName(date(2020, 2, 3))")
expect(fn.evaluate()).toEqual("Monday")
})

it("returns the day of the week name of the provided date string", () => {
const fn = math.compile("dayOfWeekName('2020-02-03')")
expect(fn.evaluate()).toEqual("Monday")
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("dayOfWeekName(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("hours", () => {
it("returns the hours of the provided date object", () => {
const fn = math.compile("hours(date(2020, 2, 3, 4, 5, 6, 7))")
expect(fn.evaluate()).toEqual(4)
})

it("returns the hours of the provided date string", () => {
const fn = math.compile("hours('2020-02-03T04:05:06.007Z')")
expect(fn.evaluate()).toEqual(4)
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("hours(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("minutes", () => {
it("returns the minutes of the provided date object", () => {
const fn = math.compile("minutes(date(2020, 2, 3, 4, 5, 6, 7))")
expect(fn.evaluate()).toEqual(5)
})

it("returns the minutes of the provided date string", () => {
const fn = math.compile("minutes('2020-02-03T04:05:06.007Z')")
expect(fn.evaluate()).toEqual(5)
})

it("returns undefined if the date is incorrect", () => {
const fn = math.compile("minutes(null)")
expect(fn.evaluate()).toEqual(UNDEF_RESULT)
})
})

describe("now", () => {
it("returns the current date", () => {
const fn = math.compile("now()")
expect(fn.evaluate()).toEqual(formatDate(new Date()))
})
})

describe("today", () => {
it("returns the current date without time", () => {
const fn = math.compile("today()")
const now = new Date()
expect(fn.evaluate()).toEqual(formatDate(new Date(now.getFullYear(), now.getMonth(), now.getDate())))
})
})
150 changes: 105 additions & 45 deletions v3/src/models/formula/functions/date-functions.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,116 @@
import { FValue } from "../formula-types"
import { UNDEF_RESULT } from "./function-utils"
import { formatDate } from "../../../utilities/date-utils"
import { fixYear } from "../../../utilities/date-parser"

/**
Returns true if the specified value should be treated as epoch
seconds when provided as the only argument to the date() function,
false if the value should be treated as a year.
date(2000) should be treated as a year, but date(12345) should not.
*/
export function defaultToEpochSecs(iValue: number) {
return Math.abs(iValue) >= 5000
}
import { convertToDate, createDate, formatDate } from "../../../utilities/date-utils"
import { t } from "../../../utilities/translation/translate"

function formatDateWithUndefFallback(date: Date) {
return formatDate(date) || UNDEF_RESULT
function formatDateWithUndefFallback(date: Date | null) {
return formatDate(date) ?? UNDEF_RESULT
}

export const dateFunctions = {
date: {
numOfRequiredArguments: 1,
evaluate: (...args: FValue[]) => {
if (args.length === 0) {
return formatDateWithUndefFallback(new Date())
}

const yearOrSeconds = args[0] != null ? Number(args[0]) : null

if (args.length === 1 && yearOrSeconds != null && defaultToEpochSecs(yearOrSeconds)) {
// Only one argument and it's a number that should be treated as epoch seconds.
// Convert from seconds to milliseconds.
return formatDateWithUndefFallback(new Date(yearOrSeconds * 1000))
}

let year = yearOrSeconds // at this point, yearOrSeconds is always interpreted as a year
const monthIndex = args[1] != null ? Math.max(0, Number(args[1]) - 1) : 0
const day = args[2] != null ? Number(args[2]) : 1
const hours = args[3] != null ? Number(args[3]) : 0
const minutes = args[4] != null ? Number(args[4]) : 0
const seconds = args[5] != null ? Number(args[5]) : 0
const milliseconds = args[6] != null ? Number(args[6]) : 0

// Logic ported from V2 for backwards compatibility
if (year == null) {
year = new Date().getFullYear() // default to current year
}
// Apply the same interpretation of the year value as the date parser
// (e.g. numbers below 100 are treated as 20xx or 19xx).
year = fixYear(year)

const date = new Date(year, monthIndex, day, hours, minutes, seconds, milliseconds)
return isNaN(date.valueOf()) ? UNDEF_RESULT : formatDateWithUndefFallback(date)
evaluate: (...args: FValue[]) => formatDateWithUndefFallback(createDate(...args as (string | number)[]))
},

year: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => {
const dateObject = convertToDate(date)
return dateObject ? dateObject.getFullYear() : UNDEF_RESULT
}
},

month: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => {
const dateObject = convertToDate(date)
// + 1 to make January 1, February 2, etc. and be backwards compatible with the V2 implementation
return dateObject ? dateObject.getMonth() + 1 : UNDEF_RESULT
}
},

monthName: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => {
const dateObject = convertToDate(date)
const monthNames = [
'DG.Formula.DateLongMonthJanuary',
'DG.Formula.DateLongMonthFebruary',
'DG.Formula.DateLongMonthMarch',
'DG.Formula.DateLongMonthApril',
'DG.Formula.DateLongMonthMay',
'DG.Formula.DateLongMonthJune',
'DG.Formula.DateLongMonthJuly',
'DG.Formula.DateLongMonthAugust',
'DG.Formula.DateLongMonthSeptember',
'DG.Formula.DateLongMonthOctober',
'DG.Formula.DateLongMonthNovember',
'DG.Formula.DateLongMonthDecember'
]
return dateObject ? t(monthNames[dateObject.getMonth()]) : UNDEF_RESULT
}
},

dayOfMonth: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => convertToDate(date)?.getDate() ?? UNDEF_RESULT
},

dayOfWeek: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => {
const dateObject = convertToDate(date)
// + 1 to make Sunday 1, Monday 2, etc. and be backwards compatible with the V2 implementation
return dateObject ? dateObject.getDay() + 1 : UNDEF_RESULT
}
},

dayOfWeekName: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => {
const dateObject = convertToDate(date)
const dayNames = [
'DG.Formula.DateLongDaySunday',
'DG.Formula.DateLongDayMonday',
'DG.Formula.DateLongDayTuesday',
'DG.Formula.DateLongDayWednesday',
'DG.Formula.DateLongDayThursday',
'DG.Formula.DateLongDayFriday',
'DG.Formula.DateLongDaySaturday'
]
return dateObject ? t(dayNames[dateObject.getDay()]) : UNDEF_RESULT
}
},

hours: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => convertToDate(date)?.getHours() ?? UNDEF_RESULT
},

minutes: {
numOfRequiredArguments: 1,
evaluate: (date: FValue) => convertToDate(date)?.getMinutes() ?? UNDEF_RESULT
},

// TODO: this revealed an issue in the new implementation (date is converted to string too early), fix it
// when the formatting / storage of dates is refactored.
// seconds: {
// numOfRequiredArguments: 1,
// evaluate: (date: FValue) => convertToDate(date)?.getSeconds() ?? UNDEF_RESULT
// },

today: {
numOfRequiredArguments: 0,
evaluate: () => {
const now = new Date()
// eliminate time within the day
return formatDateWithUndefFallback(new Date(now.getFullYear(), now.getMonth(), now.getDate()))
}
},

now: {
numOfRequiredArguments: 0,
evaluate: () => formatDateWithUndefFallback(createDate())
}
}
32 changes: 1 addition & 31 deletions v3/src/models/formula/functions/function-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,6 @@
import { isValueNonEmpty, isNumber, isValueTruthy, equal, evaluateNode } from "./function-utils"
import { isValueTruthy, equal, evaluateNode } from "./function-utils"
import { parse } from "mathjs"

describe("isValueNonEmpty", () => {
it("should return false for empty values", () => {
expect(isValueNonEmpty("")).toBe(false)
expect(isValueNonEmpty(null)).toBe(false)
expect(isValueNonEmpty(undefined)).toBe(false)
})

it("should return true for non-empty values", () => {
expect(isValueNonEmpty("non-empty")).toBe(true)
expect(isValueNonEmpty(0)).toBe(true)
expect(isValueNonEmpty(false)).toBe(true)
})
})

describe("isNumber", () => {
it("should return true for numbers", () => {
expect(isNumber(0)).toBe(true)
expect(isNumber("0")).toBe(true)
expect(isNumber(1.23)).toBe(true)
expect(isNumber("1.23")).toBe(true)
})

it("should return false for non-numbers", () => {
expect(isNumber("")).toBe(false)
expect(isNumber("abc")).toBe(false)
expect(isNumber(null)).toBe(false)
expect(isNumber(undefined)).toBe(false)
})
})

describe("isValueTruthy", () => {
it("should return true for truthy values", () => {
expect(isValueTruthy(1)).toBe(true)
Expand Down
12 changes: 4 additions & 8 deletions v3/src/models/formula/functions/function-utils.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import { MathNode } from "mathjs"
import { FormulaMathJsScope } from "../formula-mathjs-scope"
import { isValueNonEmpty } from "../../../utilities/math-utils"
export { isNumber, isValueNonEmpty } from "../../../utilities/math-utils"

export const UNDEF_RESULT = ""

export const isValueNonEmpty = (value: any) => value !== "" && value !== null && value !== undefined

// `isNumber` should be consistent within all formula functions. If more advanced parsing is necessary, MathJS
// provides its own `number` helper might be worth considering. However, besides hex number parsing, I haven't found
// any other benefits of using it (and hex numbers support doesn't seem to be necessary).
export const isNumber = (v: any) => isValueNonEmpty(v) && !isNaN(Number(v))

// CODAP formulas assume that 0 is a truthy value, which is different from default JS behavior. So that, for instance,
// count(attribute) will return a count of valid data values, since 0 is a valid numeric value.
export const isValueTruthy = (value: any) => isValueNonEmpty(value) && value !== false

export const equal = (a: any, b: any): boolean => {
export const
equal = (a: any, b: any): boolean => {
// Checks below might seem redundant once the data set cases start using typed values, but they are not.
// Note that user might still compare a string with a number unintentionally, and it makes sense to try to cast
// values when possible, so that the comparison can be performed without forcing users to think about types.
Expand Down
Loading

0 comments on commit 9f88e7d

Please sign in to comment.