diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d738d59e..f66a543f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: "20" + node-version: lts/* - name: Install NPM run: | @@ -51,7 +51,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: "20" + node-version: lts/* - name: Install NPM run: | diff --git a/.github/workflows/pr_build.yaml b/.github/workflows/pr_build.yaml index 0d640544..b64b0453 100644 --- a/.github/workflows/pr_build.yaml +++ b/.github/workflows/pr_build.yaml @@ -1,9 +1,13 @@ name: PR Build +# `pull_request`, not `pull_request_target`. The latter reads its workflow definition and its +# checkout from the base branch, so changes to this file only take effect after merging to main, +# and the job tests main rather than the pull request's code. Forks get no secrets and cannot run +# this, which is correct. on: - pull_request_target: + pull_request: branches: [ main ] - types: [ opened, reopened, synchronize, closed ] + types: [ opened, reopened, synchronize ] concurrency: group: ${{ github.ref }} @@ -17,7 +21,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: "20" + node-version: "22.14.0" - name: Install dependencies run: npm install @@ -52,4 +56,5 @@ jobs: fi - name: Clean up scratch org + if: always() run: sf org scratch delete --target-org=scratch-org --no-prompt diff --git a/docs/src/app/docs/functions/page.md b/docs/src/app/docs/functions/page.md index aa420e93..f3cf9b9c 100644 --- a/docs/src/app/docs/functions/page.md +++ b/docs/src/app/docs/functions/page.md @@ -454,9 +454,9 @@ DATETIME(2020, 1, 1, 12, 0, 0) // 2020-01-01 12:00:00 ### DATETIMEFORMAT -Formats a DateTime into a string using the provided format. +Formats a Date or DateTime into a string using the provided format. -Accepts 2 arguments: the DateTime to format and the format string. +Accepts 2 arguments: the Date or DateTime to format and the format string. ``` DATETIMEFORMAT(DATETIMEVALUE("2020-01-01 12:00:00"), "yyyy-MM-dd") // 2020-01-01 diff --git a/expression-src/main/editor/lwc/functions/functions-src.js b/expression-src/main/editor/lwc/functions/functions-src.js index 440fa3ba..61120b70 100644 --- a/expression-src/main/editor/lwc/functions/functions-src.js +++ b/expression-src/main/editor/lwc/functions/functions-src.js @@ -411,7 +411,7 @@ export const data = [ { "name": "DATETIMEFORMAT", "autoCompleteValue": "DATETIMEFORMAT(", - "description": "Formats a DateTime into a string using the provided format.

Accepts 2 arguments: the DateTime to format and the format string.", + "description": "Formats a Date or DateTime into a string using the provided format.

Accepts 2 arguments: the Date or DateTime to format and the format string.", "examples": [ "DATETIMEFORMAT(DATETIMEVALUE(\"2020-01-01 12:00:00\"), \"yyyy-MM-dd\") // 2020-01-01" ], diff --git a/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls b/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls index 7014488b..82b17f41 100644 --- a/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls +++ b/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls @@ -45,6 +45,12 @@ public with sharing class DateAndTimeFunctions { MILLISECOND, MINUTE, SECOND, HOUR_FN } + // A bare Date carries no time or timezone, so anchor it at GMT midnight. Callers must read it + // back in GMT (formatGmt/getTime) or the calendar day shifts for users west of GMT. + private static Datetime gmtMidnight(Date value) { + return Datetime.newInstanceGmt(value, Time.newInstance(0, 0, 0, 0)); + } + /** * @description Returns a date that is a specified number of days before or after a given date. * @@ -591,7 +597,7 @@ public with sharing class DateAndTimeFunctions { ); } - return Integer.valueOf(Datetime.newInstanceGmt((Date)dateValue, Time.newInstance(0, 0, 0, 0)).format('Y')); + return Integer.valueOf(gmtMidnight((Date)dateValue).format('Y')); } public override Arity getArity() { @@ -673,7 +679,7 @@ public with sharing class DateAndTimeFunctions { if (dateOrDateTimeOrTime instanceof Date) { // Convert to datetime first - Datetime gmtDatetime = Datetime.newInstanceGmt((Date)dateOrDateTimeOrTime, Time.newInstance(0, 0, 0, 0)); + Datetime gmtDatetime = gmtMidnight((Date)dateOrDateTimeOrTime); return gmtDatetime.getTime() / 1000; } else if (dateOrDateTimeOrTime instanceof Datetime) { Datetime gmtDateTime = Datetime.newInstanceGmt(((Datetime)dateOrDateTimeOrTime).date(), ((Datetime)dateOrDateTimeOrTime).time()); @@ -707,16 +713,7 @@ public with sharing class DateAndTimeFunctions { ); } - Date dateObj = (Date)dateValue; - - return Integer.valueOf(Datetime.newInstanceGmt( - dateObj.year(), - dateObj.month(), - dateObj.day(), - 0, - 0, - 0 - ).format('u')); + return Integer.valueOf(gmtMidnight((Date)dateValue).format('u')); } public override Arity getArity() { @@ -821,20 +818,21 @@ public with sharing class DateAndTimeFunctions { } /** - * @description Formats a DateTime into a string using the provided format. + * @description Formats a Date or DateTime into a string using the provided format. * - * Accepts 2 arguments: the DateTime to format and the format string. + * Accepts 2 arguments: the Date or DateTime to format and the format string. * @function DATETIMEFORMAT * @example * DATETIMEFORMAT(DATETIMEVALUE("2020-01-01 12:00:00"), "yyyy-MM-dd") // 2020-01-01 */ private class DateTimeFormatFn extends StandardFunction { public override Object call(List arguments) { - // Expect a DateTime + // A Date also passes `instanceof Datetime` in Apex, so check for Date first. Object dateTimeValue = evaluate(arguments.get(0)); - if (!(dateTimeValue instanceof Datetime)) { + Boolean isDate = dateTimeValue instanceof Date; + if (!isDate && !(dateTimeValue instanceof Datetime)) { throw new FunctionExecutionException( - 'Error executing "DATETIMEFORMAT" function: the argument must evaluate to a datetime value.' + 'Error executing "DATETIMEFORMAT" function: the first argument must evaluate to a date or datetime value.' ); } @@ -842,10 +840,15 @@ public with sharing class DateAndTimeFunctions { Object formatValue = evaluate(arguments.get(1)); if (!(formatValue instanceof String)) { throw new FunctionExecutionException( - 'Error executing "DATETIMEFORMAT" function: the argument must evaluate to a string value.' + 'Error executing "DATETIMEFORMAT" function: the second argument must evaluate to a string value.' ); } + if (isDate) { + // Format in GMT to match the anchor, so the calendar day is rendered as-is. + return gmtMidnight((Date)dateTimeValue).formatGmt((String)formatValue); + } + return ((Datetime)dateTimeValue).format((String)formatValue); } diff --git a/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls b/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls index d91185f2..4ee8029a 100644 --- a/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls +++ b/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls @@ -225,6 +225,11 @@ private class DateAndTimeFunctionsTest { Assert.areEqual('2015-01-01 01:00:00', result); } + @IsTest + private static void dateTimeFormatFormatsADateWithoutShiftingTheDay() { + Assert.areEqual('2026-09-09', Evaluator.run('DATETIMEFORMAT(DATE(2026, 9, 9), "yyyy-MM-dd")')); + } + @IsTest private static void dateToDateTimeConvertsADateToADateTime() { String formula = 'DATETODATETIME(DATEVALUE("2015-01-01"))';