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"))';