fix(core): Insights fix same day queries (#21574)

This commit is contained in:
Guillaume Jacquart 2025-11-06 16:41:22 +01:00 committed by GitHub
parent 8504beb154
commit c100736745
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 361 additions and 387 deletions

View File

@ -1,86 +1,10 @@
import type { DatabaseConfig } from '@n8n/config';
import { DateTime } from 'luxon';
import { getDateRangesCommonTableExpressionQuery } from '../insights-by-period-query.helper';
function expectLastXDaysDateRangeQuery(params: {
result: string;
dbType: DatabaseConfig['type'];
prevStartDateOffset: number;
startDateOffset: number;
}) {
const { result, dbType, prevStartDateOffset: prev, startDateOffset: start } = params;
if (dbType === 'sqlite') {
if (prev === 0) {
expect(result).toContain("datetime('now') AS prev_start_date");
} else {
expect(result).toContain(`datetime('now', '-${prev} days') AS prev_start_date`);
}
if (start === 0) {
expect(result).toContain("datetime('now') AS start_date");
} else {
expect(result).toContain(`datetime('now', '-${start} days') AS start_date`);
}
expect(result).toContain("datetime('now') AS end_date");
} else if (dbType === 'postgresdb') {
if (prev === 0) {
expect(result).toContain('NOW() AS prev_start_date');
} else {
expect(result).toContain(`NOW() - INTERVAL '${prev} days' AS prev_start_date`);
}
if (start === 0) {
expect(result).toContain('NOW() AS start_date');
} else {
expect(result).toContain(`NOW() - INTERVAL '${start} days' AS start_date`);
}
expect(result).toContain('NOW() AS end_date');
} else {
if (prev === 0) {
expect(result).toContain('NOW() AS prev_start_date');
} else {
expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${prev} DAY) AS prev_start_date`);
}
if (start === 0) {
expect(result).toContain('NOW() AS start_date');
} else {
expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${start} DAY) AS start_date`);
}
expect(result).toContain('NOW() AS end_date');
}
}
function expectStartOfDayDateRangeQuery(params: {
result: string;
dbType: DatabaseConfig['type'];
prevStartDateOffset: number;
startDateOffset: number;
endDateOffset: number;
}) {
const {
result,
dbType,
prevStartDateOffset: prev,
startDateOffset: start,
endDateOffset: end,
} = params;
if (dbType === 'sqlite') {
expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`);
expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`);
expect(result).toContain(`datetime('now', '-${end} days', 'start of day') AS end_date`);
} else if (dbType === 'postgresdb') {
expect(result).toContain(
`DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`,
);
expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`);
expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${end} days') AS end_date`);
} else {
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`);
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`);
expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${end} DAY)) AS end_date`);
}
}
import {
getDateRangesCommonTableExpressionQuery,
getDateRangesSelectQuery,
} from '../insights-by-period-query.helper';
describe('getDateRangesCommonTableExpressionQuery', () => {
const now = DateTime.utc(2025, 10, 8, 8, 51, 27);
@ -105,63 +29,83 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
const startDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
});
if (dbType === 'sqlite') {
expect(result).toContain("datetime('now', '-2 days')"); // prev_start_date
expect(result).toContain("datetime('now', '-1 days')"); // start_date
expect(result).toContain("datetime('now')"); // end_date
} else if (dbType === 'postgresdb') {
expect(result).toContain("NOW() - INTERVAL '2 days'"); // prev_start_date
expect(result).toContain("NOW() - INTERVAL '1 days'"); // start_date
expect(result).toContain('NOW()'); // end_date
} else {
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY)'); // prev_start_date
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY)'); // start_date
expect(result).toContain('NOW()'); // end_date
}
// endDate is today but different day from startDate, so dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 2 }).startOf('day'),
startDateTime: now.minus({ days: 1 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('day before yesterday (specific day)', () => {
const startDate = now.minus({ days: 2 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 3,
startDateOffset: 2,
endDateOffset: 1,
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 4 }).startOf('day'),
startDateTime: now.minus({ days: 2 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('7 days ago (specific day)', () => {
const startDate = now.minus({ days: 7 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 6 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 8,
startDateOffset: 7,
endDateOffset: 6, // the end of the range is the start of the next day
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 9 }).startOf('day'),
startDateTime: now.minus({ days: 7 }).startOf('day'),
endDateTime: now.minus({ days: 5 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('14 days ago (specific day)', () => {
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 13 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 15,
startDateOffset: 14,
endDateOffset: 13, // the end of the range is the start of the next day
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 16 }).startOf('day'),
startDateTime: now.minus({ days: 14 }).startOf('day'),
endDateTime: now.minus({ days: 12 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('X days ago (specific day far in the past)', () => {
@ -169,14 +113,21 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
const startDate = now.minus({ days: 109 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 108 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 110,
startDateOffset: 109,
endDateOffset: 108,
});
// Past range: end+1 day startOf('day')
// Duration = 2 days, so prev starts 2 days before start
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 111 }).startOf('day'),
startDateTime: now.minus({ days: 109 }).startOf('day'),
endDateTime: now.minus({ days: 107 }).startOf('day'),
});
expect(result).toBe(expected);
});
});
@ -186,39 +137,60 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
const startDate = now.minus({ days: 7 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 14, // 7 + 7
startDateOffset: 7,
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 14 }).startOf('day'),
startDateTime: now.minus({ days: 7 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('last 14 days', () => {
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 28, // 14 + 14
startDateOffset: 14,
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 28 }).startOf('day'),
startDateTime: now.minus({ days: 14 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('last 30 days', () => {
const startDate = now.minus({ days: 30 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 60, // 30 + 30
startDateOffset: 30,
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 60 }).startOf('day'),
startDateTime: now.minus({ days: 30 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
});
@ -227,70 +199,100 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
const startDate = now.minus({ days: 3 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 5,
startDateOffset: 3,
endDateOffset: 1,
});
// Past range: end+1 day, duration = 3 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 6 }).startOf('day'),
startDateTime: now.minus({ days: 3 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('5 days range', () => {
const startDate = now.minus({ days: 10 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 5 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 15,
startDateOffset: 10,
endDateOffset: 5,
});
// Past range: end+1 day, duration = 6 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 16 }).startOf('day'),
startDateTime: now.minus({ days: 10 }).startOf('day'),
endDateTime: now.minus({ days: 4 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('7 days range', () => {
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 7 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 21,
startDateOffset: 14,
endDateOffset: 7,
});
// Past range: end+1 day, duration = 8 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 22 }).startOf('day'),
startDateTime: now.minus({ days: 14 }).startOf('day'),
endDateTime: now.minus({ days: 6 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('14 days range', () => {
const startDate = now.minus({ days: 15 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 29,
startDateOffset: 15,
endDateOffset: 1,
});
// Past range: end+1 day, duration = 15 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 30 }).startOf('day'),
startDateTime: now.minus({ days: 15 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('30 days range', () => {
const startDate = now.minus({ days: 53 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 23 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 83,
startDateOffset: 53,
endDateOffset: 23,
});
// Past range: end+1 day, duration = 31 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 84 }).startOf('day'),
startDateTime: now.minus({ days: 53 }).startOf('day'),
endDateTime: now.minus({ days: 22 }).startOf('day'),
});
expect(result).toBe(expected);
});
});
});
@ -301,43 +303,68 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
const startDate = now.minus({ days: 90 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 180,
startDateOffset: 90,
});
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 180 }).startOf('day'),
startDateTime: now.minus({ days: 90 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('last 6 months', () => {
const startDate = now.minus({ months: 6 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days);
const prevDaysBack = daysBack * 2;
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: prevDaysBack,
startDateOffset: daysBack,
});
const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day');
const endDateTime = now.startOf('day');
const duration = endDateTime.diff(startDateTime);
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
test('last year', () => {
const startDate = now.minus({ years: 1 }).startOf('day').toJSDate();
const endDate = now.startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days);
const prevDaysBack = daysBack * 2;
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: prevDaysBack,
startDateOffset: daysBack,
});
const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day');
const endDateTime = now.startOf('day');
const duration = endDateTime.diff(startDateTime);
// endDate is today but different day, dates stay as-is
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
});
@ -346,56 +373,80 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
const startDate = now.minus({ days: 32 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 63,
startDateOffset: 32,
endDateOffset: 1,
});
// Past range: end+1 day, duration = 32 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 64 }).startOf('day'),
startDateTime: now.minus({ days: 32 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('90 days range (specific historical range)', () => {
const startDate = now.minus({ days: 98 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 8 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 188,
startDateOffset: 98,
endDateOffset: 8,
});
// Past range: end+1 day, duration = 91 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 189 }).startOf('day'),
startDateTime: now.minus({ days: 98 }).startOf('day'),
endDateTime: now.minus({ days: 7 }).startOf('day'),
});
expect(result).toBe(expected);
});
test('180 days range (specific historical range)', () => {
const startDate = now.minus({ days: 181 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 361,
startDateOffset: 181,
endDateOffset: 1,
});
// Past range: end+1 day, duration = 181 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 362 }).startOf('day'),
startDateTime: now.minus({ days: 181 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
test('360 days range (specific historical range)', () => {
const startDate = now.minus({ days: 361 }).startOf('day').toJSDate();
const endDate = now.minus({ days: 1 }).startOf('day').toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
expectStartOfDayDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 721,
startDateOffset: 361,
endDateOffset: 1,
});
// Past range: end+1 day, duration = 361 days
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: now.minus({ days: 722 }).startOf('day'),
startDateTime: now.minus({ days: 361 }).startOf('day'),
endDateTime: now.startOf('day'),
});
expect(result).toBe(expected);
});
});
});
@ -403,90 +454,78 @@ describe('getDateRangesCommonTableExpressionQuery', () => {
describe('edge cases', () => {
test('handles date with time component correctly', () => {
// Oct 6 14:30 to Oct 7 18:45
// Now is Oct 8 8:51:27, so Oct 7 18:45 is less than 1 full day ago
// Therefore useStartOfDay = false (not yet a full day in the past)
// daysFromEndDateToToday = Math.round(0.58) = 1
// daysDiff = Math.round(1.18) = 1
// daysFromStartDateToToday = Math.floor(1.76) = 1
// prevStartDaysFromToday = 1 + 1 = 2
// Now is Oct 8 8:51:27, so Oct 7 18:45 is in the past
const startDate = DateTime.utc(2025, 10, 6, 14, 30, 0).toJSDate();
const endDate = DateTime.utc(2025, 10, 7, 18, 45, 30).toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
});
// Verify the actual output based on the calculation
if (dbType === 'sqlite') {
expect(result).toContain("datetime('now', '-2 days') AS prev_start_date");
expect(result).toContain("datetime('now', '-1 days') AS start_date");
expect(result).toContain("datetime('now', '-1 days') AS end_date");
} else if (dbType === 'postgresdb') {
expect(result).toContain("NOW() - INTERVAL '2 days' AS prev_start_date");
expect(result).toContain("NOW() - INTERVAL '1 days' AS start_date");
expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date");
} else {
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY) AS prev_start_date');
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS start_date');
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date');
}
// Past range, take full days
const startDateTime = DateTime.utc(2025, 10, 6, 14, 30, 0).startOf('day');
const endDateTime = DateTime.utc(2025, 10, 7, 18, 45, 30).plus({ days: 1 }).startOf('day');
const duration = endDateTime.diff(startDateTime);
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
test('handles same day with different times correctly (hour periodicity)', () => {
// Oct 7 9:00 to Oct 7 17:00 (same day)
// Now is Oct 8 8:51:27, so Oct 7 17:00 is less than 1 full day ago
// useStartOfDay = false
// daysDiff = 0 (same day), daysFromEndDateToToday = 1 (rounded)
// daysFromStartDateToToday = 0 (floored), prevStartDaysFromToday = 0 + 0 = 0
// Now is Oct 8 8:51:27
const startDate = DateTime.utc(2025, 10, 7, 9, 0, 0).toJSDate();
const endDate = DateTime.utc(2025, 10, 7, 17, 0, 0).toJSDate();
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
// Verify the actual output based on the calculation
if (dbType === 'sqlite') {
expect(result).toContain("datetime('now') AS prev_start_date");
expect(result).toContain("datetime('now') AS start_date");
expect(result).toContain("datetime('now', '-1 days') AS end_date");
} else if (dbType === 'postgresdb') {
expect(result).toContain('NOW() AS prev_start_date');
expect(result).toContain('NOW() AS start_date');
expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date");
} else {
expect(result).toContain('NOW() AS prev_start_date');
expect(result).toContain('NOW() AS start_date');
expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date');
}
});
test('handles daylight saving time transition correctly', () => {
// Simulate DST transition: Oct 22 (GMT+0200) to Nov 5 (GMT+0100)
// Same wall-clock time but different timezone offset
// Oct 26 2025 is when DST ends in Europe (clocks go back 1 hour)
const startDate = DateTime.fromObject(
{ year: 2025, month: 10, day: 22, hour: 12, minute: 37, second: 56 },
{ zone: 'Europe/Paris' },
).toJSDate();
const endDate = DateTime.fromObject(
{ year: 2025, month: 11, day: 5, hour: 12, minute: 37, second: 56 },
{ zone: 'Europe/Paris' },
).toJSDate();
// Mock current time to be Nov 5
jest.setSystemTime(endDate);
const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate });
// With DST normalization: Oct 22 to Nov 5 is 14 calendar days
// The function detects same wall-clock time but different timezone offset
// and normalizes to calculate correct calendar days
expectLastXDaysDateRangeQuery({
result,
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
prevStartDateOffset: 28, // 14 + 14
startDateOffset: 14,
});
// Restore original mock time
jest.setSystemTime(now.toJSDate());
// Past range, take full days
const startDateTime = DateTime.utc(2025, 10, 7, 9, 0, 0).startOf('day');
const endDateTime = DateTime.utc(2025, 10, 7, 17, 0, 0).plus({ days: 1 }).startOf('day');
const duration = endDateTime.diff(startDateTime);
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
test('handle current day as both start and end date', () => {
const startDate = now.toJSDate();
const endDate = now.toJSDate();
const result = getDateRangesCommonTableExpressionQuery({
startDate,
endDate,
dbType,
});
// startDate and endDate are today, so start is startOf('day'), end is now
const startDateTime = now.startOf('day');
const endDateTime = now;
const duration = endDateTime.diff(startDateTime);
const expected = getDateRangesSelectQuery({
dbType,
prevStartDateTime: startDateTime.minus(duration),
startDateTime,
endDateTime,
});
expect(result).toBe(expected);
});
});
});

View File

@ -2,148 +2,83 @@ import type { DatabaseConfig } from '@n8n/config';
import { sql } from '@n8n/db';
import { DateTime } from 'luxon';
/**
* Generates database-specific SQL for a datetime value relative to now
* @param dbType - The database type
* @param daysFromToday - Number of days back from today (0 = now)
* @param useStartOfDay - Whether to truncate to start of day (00:00:00)
*/
const getDatetimeSql = ({
dbType,
daysFromToday,
useStartOfDay = false,
}: {
dbType: DatabaseConfig['type'];
daysFromToday: number;
useStartOfDay?: boolean;
}): string => {
// Handle "now" case
if (daysFromToday === 0 && !useStartOfDay) {
return dbType === 'sqlite' ? "datetime('now')" : 'NOW()';
}
// SQLite
if (dbType === 'sqlite') {
if (daysFromToday === 0 && useStartOfDay) {
return "datetime('now', 'start of day')";
}
if (useStartOfDay) {
return `datetime('now', '-${daysFromToday} days', 'start of day')`;
}
return `datetime('now', '-${daysFromToday} days')`;
}
// PostgreSQL
if (dbType === 'postgresdb') {
if (daysFromToday === 0 && useStartOfDay) {
return "DATE_TRUNC('day', NOW())";
}
if (useStartOfDay) {
return `DATE_TRUNC('day', NOW() - INTERVAL '${daysFromToday} days')`;
}
return `NOW() - INTERVAL '${daysFromToday} days'`;
}
// MySQL/MariaDB
if (daysFromToday === 0 && useStartOfDay) {
return 'DATE(NOW())';
}
if (useStartOfDay) {
return `DATE(DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY))`;
}
return `DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY)`;
};
/**
* Generates a SQL Common Table Expression (CTE) query that provides three date boundaries for insights queries
*
* Behavior:
* - If startDate and endDate are the same and today
* - returns the last 24 hours: prev_start_date (2 days ago), start_date (1 day ago), end_date (now).
* - Otherwise:
* - prev_start_date: start of the day before the range
* - start_date: start of the current range
* - end_date: "now" if endDate is today, else start of the day after endDate
* - If the end date is today and the start date is also today, start date is set to the start of the day to take today's data.
* - If the end date is in the past, both start and end dates are set to the start of their respective days, to take full days.
*
* The SQL CTE can be joined with the insights table for filtering/aggregation.
*
* @param dbType - The database type ('sqlite', 'postgresdb', 'mysqldb', 'mariadb')
* @param startDate - The start date of the range (inclusive)
* @param endDate - The end date of the range (inclusive, or "now" if today)
* @param dbType - The database type (postgresdb, mysqldb, mariadb, or sqlite)
* @returns SQL CTE query with `prev_start_date`, `start_date`, and `end_date` columns
* - `prev_start_date`: The start of the previous period (used for comparison)
* - `start_date`: The start of the current period (inclusive)
* - `end_date`: The end of the current period (exclusive)
*/
export const getDateRangesCommonTableExpressionQuery = ({
dbType,
startDate,
endDate,
dbType,
}: {
dbType: DatabaseConfig['type'];
startDate: Date;
endDate: Date;
dbType: DatabaseConfig['type'];
}) => {
let today = DateTime.now();
let startDateTime = DateTime.fromJSDate(startDate);
let endDateTime = DateTime.fromJSDate(endDate);
let startDateTime = DateTime.fromJSDate(startDate).toUTC();
let endDateTime = DateTime.fromJSDate(endDate).toUTC();
// If the end date is in a past day, use start of day for both dates
const useStartOfDay = today.diff(endDateTime, 'days').days >= 1;
const today = DateTime.now().toUTC();
const isEndDateToday = endDateTime.hasSame(today, 'day');
if (useStartOfDay) {
// Past range, take full days
if (!isEndDateToday) {
startDateTime = startDateTime.startOf('day');
endDateTime = endDateTime.startOf('day');
today = today.startOf('day');
endDateTime = endDateTime.plus({ days: 1 }).startOf('day');
}
// Check if times are exactly the same but timezone differs (DST transition case)
const offsetDiff = Math.abs(startDateTime.offset - endDateTime.offset);
// If same wall-clock time but different timezone offset (max 2 hours), normalize to same timezone
if (
startDateTime.hour === endDateTime.hour &&
startDateTime.minute === endDateTime.minute &&
startDateTime.second === endDateTime.second &&
offsetDiff > 0 &&
offsetDiff <= 120 // Max 2 hours difference in minutes
) {
// Change the startDateTime to so that time matches
startDateTime = startDateTime.plus({ minutes: offsetDiff });
// Today range, take all day data starting from the beginning of the day
if (isEndDateToday && startDateTime.hasSame(endDateTime, 'day')) {
startDateTime = startDateTime.startOf('day');
}
// Convert to UTC to avoid DST issues when calculating day differences
const startDateTimeUTC = startDateTime.toUTC();
const endDateTimeUTC = endDateTime.toUTC();
const todayUTC = today.toUTC();
const prevStartDateTime = startDateTime.minus(endDateTime.diff(startDateTime));
const daysFromEndDateToToday = Math.round(todayUTC.diff(endDateTimeUTC, 'days').days);
const daysDiff = Math.round(endDateTimeUTC.diff(startDateTimeUTC, 'days').days);
return getDateRangesSelectQuery({ dbType, prevStartDateTime, startDateTime, endDateTime });
};
const daysFromStartDateToToday = Math.floor(todayUTC.diff(startDateTimeUTC, 'days').days);
const prevStartDaysFromToday = daysFromStartDateToToday + daysDiff;
export function getDateRangesSelectQuery({
dbType,
prevStartDateTime,
startDateTime,
endDateTime,
}: {
dbType: DatabaseConfig['type'];
prevStartDateTime: DateTime;
startDateTime: DateTime;
endDateTime: DateTime;
}) {
const prevStartStr = prevStartDateTime.toSQL({ includeZone: false, includeOffset: false });
const startStr = startDateTime.toSQL({ includeZone: false, includeOffset: false });
const endStr = endDateTime.toSQL({ includeZone: false, includeOffset: false });
const prevStartDateSql = getDatetimeSql({
dbType,
daysFromToday: prevStartDaysFromToday,
useStartOfDay,
});
const startDateSql = getDatetimeSql({
dbType,
daysFromToday: daysFromStartDateToToday,
useStartOfDay,
});
const endDateSql = getDatetimeSql({
dbType,
daysFromToday: daysFromEndDateToToday,
useStartOfDay,
});
// Database-specific timestamp casting
// PostgreSQL requires explicit CAST or :: syntax for timestamp comparisons
// SQLite and MySQL/MariaDB can work with string literals in comparisons
if (dbType === 'postgresdb') {
return sql`SELECT
CAST('${prevStartStr}' AS TIMESTAMP) AS prev_start_date,
CAST('${startStr}' AS TIMESTAMP) AS start_date,
CAST('${endStr}' AS TIMESTAMP) AS end_date
`;
}
return sql`SELECT
${prevStartDateSql} AS prev_start_date,
${startDateSql} AS start_date,
${endDateSql} AS end_date
'${prevStartStr}' AS prev_start_date,
'${startStr}' AS start_date,
'${endStr}' AS end_date
`;
};
}