Enhance date handling and filtering capabilities in utility functions and tests
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit introduces new functions for handling various date operations, including start and end of day, week, month, hour, and minute. It also adds relative date parsing capabilities, allowing for expressions like 'TODAY', '-7D..', and '-1M..-CM'. Additionally, the `parseFilter` function is updated to support these new date functionalities, with corresponding tests added to ensure accurate date filtering and validation. The changes improve the overall date management and querying capabilities within the application.
This commit is contained in:
Tom Butcher 2026-09-04 00:36:22 +01:00
parent 9b896c2157
commit 605d845ba3
8 changed files with 756 additions and 167 deletions

View File

@ -1,7 +1,67 @@
import { describe, expect, it } from '@jest/globals';
import { parseFilter } from '../utils.js';
import { getFilter, parseFilter } from '../utils.js';
import { jobModel } from '../database/schemas/production/job.schema.js';
const startOfDay = (date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
const endOfDay = (date) => {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
};
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
const endOfMonth = (date) =>
new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
const startOfWeek = (date) => {
const d = startOfDay(date);
const day = d.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + mondayOffset);
return d;
};
const endOfWeek = (date) => {
const start = startOfWeek(date);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return endOfDay(end);
};
const startOfYear = (date) => new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0);
const endOfYear = (date) => new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999);
const startOfHour = (date) => {
const d = new Date(date);
d.setMinutes(0, 0, 0);
return d;
};
const endOfHour = (date) => {
const d = new Date(date);
d.setMinutes(59, 59, 999);
return d;
};
const startOfMinute = (date) => {
const d = new Date(date);
d.setSeconds(0, 0);
return d;
};
const endOfMinute = (date) => {
const d = new Date(date);
d.setSeconds(59, 999);
return d;
};
describe('parseFilter number fields', () => {
it('matches exact numeric quantity without regex', async () => {
await expect(parseFilter('quantity', '12', jobModel)).resolves.toEqual({ quantity: 12 });
@ -51,3 +111,114 @@ describe('parseFilter number fields', () => {
});
});
});
describe('parseFilter date fields', () => {
it('matches TODAY as the current calendar day', async () => {
const now = new Date();
await expect(parseFilter('createdAt', 'TODAY', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) },
});
});
it('matches TODAY.. as from today onwards', async () => {
const now = new Date();
await expect(parseFilter('createdAt', 'TODAY..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(now) },
});
});
it('matches -7D.. as the last 7 days', async () => {
const from = new Date();
from.setDate(from.getDate() - 7);
await expect(parseFilter('createdAt', '-7D..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(from) },
});
});
it('matches -30D.. as the last 30 days', async () => {
const from = new Date();
from.setDate(from.getDate() - 30);
await expect(parseFilter('createdAt', '-30D..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfDay(from) },
});
});
it('matches -1M..-CM as the previous calendar month', async () => {
const now = new Date();
const previous = new Date(now.getFullYear(), now.getMonth() - 1, 1);
await expect(parseFilter('createdAt', '-1M..-CM', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfMonth(previous), $lte: endOfMonth(previous) },
});
});
it('matches -1W as the previous calendar week', async () => {
const previous = new Date();
previous.setDate(previous.getDate() - 7);
await expect(parseFilter('createdAt', '-1W', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfWeek(previous), $lte: endOfWeek(previous) },
});
});
it('matches -1Y..-CY as the previous calendar year', async () => {
const previous = new Date(new Date().getFullYear() - 1, 0, 1);
await expect(parseFilter('createdAt', '-1Y..-CY', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfYear(previous), $lte: endOfYear(previous) },
});
});
it('matches -2H as that hour two hours ago', async () => {
const hour = new Date();
hour.setHours(hour.getHours() - 2);
await expect(parseFilter('createdAt', '-2H', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfHour(hour), $lte: endOfHour(hour) },
});
});
it('matches -15m.. as the last 15 minutes', async () => {
const from = new Date();
from.setMinutes(from.getMinutes() - 15);
await expect(parseFilter('createdAt', '-15m..', jobModel)).resolves.toEqual({
createdAt: { $gte: startOfMinute(from) },
});
});
it('does not treat lowercase m as a month', async () => {
const month = await parseFilter('createdAt', '-1M', jobModel);
const minute = await parseFilter('createdAt', '-1m', jobModel);
expect(month).not.toEqual(minute);
const from = new Date();
from.setMinutes(from.getMinutes() - 1);
expect(minute).toEqual({
createdAt: { $gte: startOfMinute(from), $lte: endOfMinute(from) },
});
});
it('omits invalid date text instead of sending it to Mongo', async () => {
await expect(parseFilter('createdAt', 'not-a-date', jobModel)).resolves.toEqual({});
});
it('omits expressions that mix dates with non-date operands', async () => {
await expect(parseFilter('createdAt', 'TODAY|foo', jobModel)).resolves.toEqual({});
});
it('rejects impossible calendar days', async () => {
await expect(parseFilter('createdAt', '32', jobModel)).resolves.toEqual({});
});
it('getFilter drops invalid date fields and keeps valid ones', async () => {
const now = new Date();
const filter = await getFilter(
{ createdAt: 'yesterday', quantity: '12' },
['createdAt', 'quantity'],
true,
jobModel
);
expect(filter).toEqual({ quantity: 12 });
expect(filter.createdAt).toBeUndefined();
const today = await getFilter({ createdAt: 'TODAY' }, ['createdAt'], true, jobModel);
expect(today).toEqual({
createdAt: { $gte: startOfDay(now), $lte: endOfDay(now) },
});
});
});

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from '@jest/globals';
import mongoose from 'mongoose';
import { convertObjectIdStringsInFilter } from '../utils.js';
describe('convertObjectIdStringsInFilter', () => {
it('preserves Date bounds on comparison operators', () => {
const start = new Date('2026-08-04T23:00:00.000Z');
const end = new Date('2026-09-04T22:59:59.999Z');
expect(
convertObjectIdStringsInFilter({
createdAt: { $gte: start, $lte: end },
})
).toEqual({
createdAt: { $gte: start, $lte: end },
});
});
it('converts ObjectId strings while leaving Date values intact', () => {
const start = new Date('2026-08-01T00:00:00.000Z');
const id = '691a537b1e8acd0b40114206';
const result = convertObjectIdStringsInFilter({
owner: id,
createdAt: { $gte: start },
});
expect(result.owner).toEqual(new mongoose.Types.ObjectId(id));
expect(result.createdAt.$gte).toBe(start);
});
});

View File

@ -49,6 +49,7 @@ function extractReferenceId(value) {
/** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */
export function convertObjectIdStringsInFilter(filter) {
if (filter instanceof Date || filter instanceof mongoose.Types.ObjectId) return filter;
if (!filter || typeof filter !== 'object') return filter;
const result = {};

View File

@ -7,6 +7,7 @@ import {
getAuditLogStatsRouteHandler,
getAuditLogHistoryRouteHandler,
searchAuditLogsRouteHandler,
getAuditLogPropertyValuesRouteHandler,
} from '../../services/management/auditlogs.js';
import { getFilter, getSort } from '../../utils.js';
@ -15,22 +16,26 @@ const router = express.Router();
const listAllowedFilters = [
'parent._id',
'owner._id',
'owner',
'parent',
'operation',
'createdAt',
'updatedAt',
'_reference',
];
const listAllowedSorters = ['createdAt', 'updatedAt'];
const listAllowedSorters = ['createdAt', 'updatedAt', 'owner', 'parent', 'operation'];
router.get('/', isAuthenticated, checkPermissions('auditLog', 'list'), async (req, res) => {
const { page, limit, sortProperty, sortOrder } = req.query;
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
listAuditLogsRouteHandler(
req,
res,
page,
limit,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder
);
@ -45,11 +50,32 @@ router.get('/stats', isAuthenticated, async (req, res) => {
getAuditLogStatsRouteHandler(req, res);
});
router.get('/history', isAuthenticated, async (req, res) => {
router.get('/values', checkPermissions('auditLog', 'list'), isAuthenticated, async (req, res) => {
const { property } = req.query;
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true);
}
getAuditLogPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
});
router.get(
'/:id/properties',
checkPermissions('auditLog', 'list'),
isAuthenticated,
async (req, res) => {
const { id } = req.params;
const { property } = req.query;
getAuditLogPropertyValuesRouteHandler(req, res, property);
}
);
router.get('/history', checkPermissions('auditLog', 'list'), isAuthenticated, async (req, res) => {
getAuditLogHistoryRouteHandler(req, res);
});
router.get('/:id', async (req, res) => {
router.get('/:id', checkPermissions('auditLog', 'list'), isAuthenticated, async (req, res) => {
await getAuditLogRouteHandler(req, res);
});

View File

@ -22,31 +22,41 @@ import {
getPermissionSettingsNeighborsRouteHandler,
} from '../../services/management/permissionsetting.js';
router.get('/', isAuthenticated, checkPermissions('permissionSetting', 'list'), async (req, res) => {
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
listPermissionSettingsRouteHandler(
req,
res,
page,
limit,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder
);
});
router.get('/properties', checkPermissions('permissionSetting', 'list'), isAuthenticated, async (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
router.get(
'/',
isAuthenticated,
checkPermissions('permissionSetting', 'list'),
async (req, res) => {
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
listPermissionSettingsRouteHandler(
req,
res,
page,
limit,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder
);
}
listPermissionSettingsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
);
router.get(
'/properties',
checkPermissions('permissionSetting', 'list'),
isAuthenticated,
async (req, res) => {
let properties = convertPropertiesString(req.query.properties);
const filter = await getFilter(req.query, propertiesAllowedFilters, false);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = JSON.parse(req.query.masterFilter);
}
listPermissionSettingsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
}
);
router.get(
'/values',
@ -57,58 +67,94 @@ router.get(
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(
JSON.parse(req.query.masterFilter),
listAllowedFilters,
true
);
masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true);
}
getPermissionSettingsPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
}
);
router.get('/search', checkPermissions('permissionSetting', 'list'), isAuthenticated, async (req, res) => {
const { search } = req.query;
searchPermissionSettingsRouteHandler(req, res, search);
});
router.get(
'/search',
checkPermissions('permissionSetting', 'list'),
isAuthenticated,
async (req, res) => {
const { search } = req.query;
searchPermissionSettingsRouteHandler(req, res, search);
}
);
router.post('/', isAuthenticated, checkPermissions('permissionSetting', 'new'), async (req, res) => {
newPermissionSettingsRouteHandler(req, res);
});
router.post(
'/',
isAuthenticated,
checkPermissions('permissionSetting', 'new'),
async (req, res) => {
newPermissionSettingsRouteHandler(req, res);
}
);
router.get('/stats', isAuthenticated, async (req, res) => {
getPermissionSettingsStatsRouteHandler(req, res);
});
router.get(
'/stats',
isAuthenticated,
checkPermissions('permissionSetting', 'list'),
async (req, res) => {
getPermissionSettingsStatsRouteHandler(req, res);
}
);
router.get('/history', isAuthenticated, async (req, res) => {
getPermissionSettingsHistoryRouteHandler(req, res);
});
router.get(
'/history',
isAuthenticated,
checkPermissions('permissionSetting', 'list'),
async (req, res) => {
getPermissionSettingsHistoryRouteHandler(req, res);
}
);
router.get('/neighbors', isAuthenticated, async (req, res) => {
const { property, search, sortProperty, sortOrder, id } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
getPermissionSettingsNeighborsRouteHandler(
req,
res,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder,
id
);
});
router.get(
'/neighbors',
isAuthenticated,
checkPermissions('permissionSetting', 'list'),
async (req, res) => {
const { property, search, sortProperty, sortOrder, id } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
getPermissionSettingsNeighborsRouteHandler(
req,
res,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder,
id
);
}
);
router.get('/:id', isAuthenticated, async (req, res) => {
getPermissionSettingsRouteHandler(req, res);
});
router.get(
'/:id',
isAuthenticated,
checkPermissions('permissionSetting', 'info'),
async (req, res) => {
getPermissionSettingsRouteHandler(req, res);
}
);
router.put('/:id', isAuthenticated, checkPermissions('permissionSetting', 'edit'), async (req, res) => {
editPermissionSettingsRouteHandler(req, res);
});
router.put(
'/:id',
isAuthenticated,
checkPermissions('permissionSetting', 'edit'),
async (req, res) => {
editPermissionSettingsRouteHandler(req, res);
}
);
router.delete('/:id', isAuthenticated, async (req, res) => {
deletePermissionSettingsRouteHandler(req, res);
});
router.delete(
'/:id',
isAuthenticated,
checkPermissions('permissionSetting', 'delete'),
async (req, res) => {
deletePermissionSettingsRouteHandler(req, res);
}
);
export default router;

View File

@ -29,15 +29,24 @@ import {
setAppPasswordRouteHandler,
searchUsersRouteHandler,
getUserPropertyValuesRouteHandler,
getUserNeighborsRouteHandler
getUserNeighborsRouteHandler,
} from '../../services/management/users.js';
// list of document templates
router.get('/', isAuthenticated, checkPermissions('user', 'list'), async (req, res) => {
const { page, limit, property, search, sortProperty, sortOrder } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
listUsersRouteHandler(req, res, page, limit, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder);
const filter = await getFilter(req.query, listAllowedFilters);
listUsersRouteHandler(
req,
res,
page,
limit,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder
);
});
router.get('/properties', checkPermissions('user', 'list'), isAuthenticated, async (req, res) => {
@ -50,30 +59,20 @@ router.get('/properties', checkPermissions('user', 'list'), isAuthenticated, asy
listUsersByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
});
router.get(
'/values',
checkPermissions('user', 'list'),
isAuthenticated,
async (req, res) => {
const { property } = req.query;
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(
JSON.parse(req.query.masterFilter),
listAllowedFilters,
true
);
}
getUserPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
router.get('/values', checkPermissions('user', 'list'), isAuthenticated, async (req, res) => {
const { property } = req.query;
const filter = await getFilter(req.query, listAllowedFilters, true);
var masterFilter = {};
if (req.query.masterFilter) {
masterFilter = await getFilter(JSON.parse(req.query.masterFilter), listAllowedFilters, true);
}
);
getUserPropertyValuesRouteHandler(req, res, property, filter, masterFilter);
});
router.get('/search', checkPermissions('user', 'list'), isAuthenticated, async (req, res) => {
const { search } = req.query;
searchUsersRouteHandler(req, res, search);
});
// get user stats
router.get('/stats', isAuthenticated, async (req, res) => {
getUserStatsRouteHandler(req, res);
@ -87,10 +86,19 @@ router.get('/history', isAuthenticated, async (req, res) => {
router.get('/neighbors', isAuthenticated, async (req, res) => {
const { property, search, sortProperty, sortOrder, id } = req.query;
const filter = await getFilter(req.query, listAllowedFilters);
getUserNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id);
getUserNeighborsRouteHandler(
req,
res,
property,
filter,
search,
getSort(sortProperty, listAllowedSorters),
sortOrder,
id
);
});
router.get('/:id', isAuthenticated, async (req, res) => {
router.get('/:id', isAuthenticated, checkPermissions('user', 'info'), async (req, res) => {
getUserRouteHandler(req, res);
});
@ -99,8 +107,13 @@ router.put('/:id', isAuthenticated, checkPermissions('user', 'edit'), async (req
editUserRouteHandler(req, res);
});
router.post('/:id/setAppPassword', isAuthenticated, checkPermissions('user', 'newAppPassword'), async (req, res) => {
setAppPasswordRouteHandler(req, res);
});
router.post(
'/:id/setAppPassword',
isAuthenticated,
checkPermissions('user', 'newAppPassword'),
async (req, res) => {
setAppPasswordRouteHandler(req, res);
}
);
export default router;

View File

@ -2,55 +2,51 @@ import config from '../../config.js';
import { auditLogModel } from '../../database/schemas/management/auditlog.schema.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import { getModelStats, getModelHistory, searchObjects } from '../../database/database.js';
import {
getModelStats,
getModelHistory,
searchObjects,
getObject,
getPropertyValues,
listObjects,
} from '../../database/database.js';
const logger = log4js.getLogger('AuditLogs');
logger.level = config.server.logLevel;
const AUDIT_LOG_POPULATE = ['owner', 'parent'];
export const listAuditLogsRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
try {
// Calculate the skip value based on the page number and limit
const skip = (page - 1) * limit;
const sortOrder = order === 'descend' ? 1 : -1;
const result = await listObjects({
model: auditLogModel,
page,
limit,
property,
filter,
search,
sort,
order,
populate: AUDIT_LOG_POPULATE,
});
if (!sort || sort != '') {
sort = 'createdAt';
}
// Use find with population and filter
let query = auditLogModel
.find(filter)
.sort({ [sort]: sortOrder })
.skip(skip)
.limit(Number(limit))
.populate([
{ path: 'owner', select: 'name _id color' },
{ path: 'parent', select: '_id name' },
]);
const auditLogs = await query;
logger.trace(
`List of audit logs (Page ${page}, Limit ${limit}, Sort ${sort}, Order ${order}):`,
auditLogs
);
const expandedIdAuditLogs = auditLogs.map((auditLog) => {
const expendedAuditLog = { ...auditLog._doc };
return expendedAuditLog;
});
res.send(expandedIdAuditLogs);
} catch (error) {
logger.error('Error listing audit logs:', error);
res.status(500).send({ error: error });
if (result?.error) {
logger.error('Error listing audit logs.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of audit logs (Page ${page}, Limit ${limit}). Count: ${result.length}.`);
res.send(result);
};
export const searchAuditLogsRouteHandler = async (req, res, search) => {
@ -61,30 +57,34 @@ export const searchAuditLogsRouteHandler = async (req, res, search) => {
res.send(result);
};
export const getAuditLogPropertyValuesRouteHandler = async (
req,
res,
property,
filter,
masterFilter
) => {
const result = await getPropertyValues({
model: auditLogModel,
property,
filter: { ...filter, ...masterFilter },
});
res.send(result);
};
export const getAuditLogRouteHandler = async (req, res) => {
try {
// Get ID from params
const id = new mongoose.Types.ObjectId(req.params.id);
// Fetch the audit log with the given ID
const auditLog = await auditLogModel
.findOne({
_id: id,
})
.populate('printer')
.populate('owner')
.populate('parent');
if (!auditLog) {
logger.warn(`Audit log not found with supplied id.`);
return res.status(404).send({ error: 'Audit log not found.' });
}
logger.trace(`Audit log with ID: ${id}:`, auditLog);
res.send(auditLog);
} catch (error) {
logger.error('Error fetching audit log:', error);
res.status(500).send({ error: error.message });
const id = req.params.id;
const result = await getObject({
model: auditLogModel,
id,
populate: AUDIT_LOG_POPULATE,
});
if (result?.error) {
logger.warn(`Audit log not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retreived audit log with ID: ${id}`);
res.send(result);
};
export const getAuditLogStatsRouteHandler = async (req, res) => {

View File

@ -42,9 +42,35 @@ logger.level = config.server.logLevel;
// ? a single character Hans?n
// @ ignore case @location (text matching is case-insensitive)
//
// Date-typed fields (detected by name, e.g. *At / *Date / *Time) interpret bare
// values as calendar days/datetimes, e.g. "22" => the whole of day 22 of the
// current month/year, "22..23" => start of 22 through end of 23.
// Date-typed fields (schema Date) interpret bare values as calendar
// days/datetimes, e.g. "22" => the whole of day 22 of the current month/year,
// "22..23" => start of 22 through end of 23.
//
// Relative date operands:
// TODAY current calendar day
// TODAY.. from start of today onwards
// -7D.. from 7 days ago onwards (last 7 days)
// -30D.. from 30 days ago onwards
// -1W previous calendar week (Monday–Sunday)
// -1M previous calendar month (as a whole day-range)
// -1M..-CM previous calendar month (from its start up to current month)
// -1Y previous calendar year
// -nD / +nD n days before / after today
// -nW / +nW n calendar weeks before / after the current week
// -nM / +nM n calendar months before / after the current month
// -nY / +nY n calendar years before / after the current year
// -nH / +nH n hours before / after the current hour
// -nm / +nm n minutes before / after the current minute (lowercase m)
// CW / CM / CY / CH / Cm current week / month / year / hour / minute
// -CW / -CM / -CY / -CH / -Cm
// current-unit boundary (start of this unit / end of last unit)
//
// Unit letters: D day, W week, M month, Y year, H hour, m minute.
// M (month) and m (minute) are case-sensitive; other units are not.
//
// An invalid date operand (anything other than a date, relative date, or
// filter operator) omits that field from the query instead of sending it to
// MongoDB.
//
// Note: BC's space-delimited date shorthand is locale dependent; here numeric
// date operands are read day-first ("D M Y H Min S") with missing components
@ -60,11 +86,6 @@ function buildWildcardRegexPattern(input) {
return `^${withWildcards}$`;
}
function looksLikeDateField(property) {
const last = String(property).split('.').pop();
return /[a-z](?:At|Date|Time)$/.test(last) || /^(?:date|time|datetime)$/i.test(last);
}
const NO_MATCH_CONDITION = { op: { $in: [] } };
function isBooleanField(property, model = null) {
@ -104,6 +125,30 @@ function isNumberField(property, model = null) {
return false;
}
function isDateTimeSchemaPath(path) {
if (!path) return false;
if (path.instance === 'Date') return true;
if (path.instance === 'Array') {
return getEmbeddedSchemaType(path)?.instance === 'Date';
}
return false;
}
function isDateTimeField(property, model = null) {
if (model?.schema) {
return isDateTimeSchemaPath(model.schema.path(property));
}
for (const entry of Object.values(models)) {
if (isDateTimeSchemaPath(entry.model?.schema?.path(property))) return true;
}
return false;
}
function buildNumberRegexCondition(property, pattern, negated = false) {
const expr = {
$regexMatch: {
@ -329,11 +374,207 @@ function endOfDay(date) {
return d;
}
function startOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
}
function endOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
}
function shiftCalendarMonths(date, n) {
return new Date(date.getFullYear(), date.getMonth() + n, 1);
}
function startOfWeek(date) {
const d = startOfDay(date);
const day = d.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + mondayOffset);
return d;
}
function endOfWeek(date) {
const start = startOfWeek(date);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return endOfDay(end);
}
function shiftWeeks(date, n) {
const d = new Date(date);
d.setDate(d.getDate() + n * 7);
return d;
}
function startOfYear(date) {
return new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0);
}
function endOfYear(date) {
return new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999);
}
function shiftYears(date, n) {
return new Date(date.getFullYear() + n, 0, 1);
}
function startOfHour(date) {
const d = new Date(date);
d.setMinutes(0, 0, 0);
return d;
}
function endOfHour(date) {
const d = new Date(date);
d.setMinutes(59, 59, 999);
return d;
}
function startOfMinute(date) {
const d = new Date(date);
d.setSeconds(0, 0);
return d;
}
function endOfMinute(date) {
const d = new Date(date);
d.setSeconds(59, 999);
return d;
}
function normalizeYear(year) {
if (year >= 100) return year;
return year < 70 ? 2000 + year : 1900 + year;
}
const RELATIVE_OFFSET_RE = /^([+-])?(\d+)([DdWwYyHhMm])$/;
const RELATIVE_CURRENT_RE = /^([+-])?C([DdWwYyHhMm])$/;
function normalizeRelativeUnit(letter) {
if (letter === 'M' || letter === 'm') return letter;
return letter.toUpperCase();
}
function resolveRelativeUnit(now, unit, amount, boundary, isCurrent, minusCurrent) {
const end = boundary === 'end';
if (unit === 'D') {
if (isCurrent) return end ? endOfDay(now) : startOfDay(now);
const date = new Date(now);
date.setDate(date.getDate() + amount);
return end ? endOfDay(date) : startOfDay(date);
}
if (unit === 'W') {
if (isCurrent && minusCurrent) {
return end ? endOfWeek(shiftWeeks(now, -1)) : startOfWeek(now);
}
const target = isCurrent ? now : shiftWeeks(now, amount);
return end ? endOfWeek(target) : startOfWeek(target);
}
if (unit === 'M') {
if (isCurrent) {
if (minusCurrent) {
return end ? endOfMonth(shiftCalendarMonths(now, -1)) : startOfMonth(now);
}
return end ? endOfMonth(now) : startOfMonth(now);
}
const month = shiftCalendarMonths(now, amount);
return end ? endOfMonth(month) : startOfMonth(month);
}
if (unit === 'Y') {
if (isCurrent) {
if (minusCurrent) {
return end ? endOfYear(shiftYears(now, -1)) : startOfYear(now);
}
return end ? endOfYear(now) : startOfYear(now);
}
const year = shiftYears(now, amount);
return end ? endOfYear(year) : startOfYear(year);
}
if (unit === 'H') {
if (isCurrent) {
if (minusCurrent) {
const prev = new Date(now);
prev.setHours(prev.getHours() - 1);
return end ? endOfHour(prev) : startOfHour(now);
}
return end ? endOfHour(now) : startOfHour(now);
}
const date = new Date(now);
date.setHours(date.getHours() + amount);
return end ? endOfHour(date) : startOfHour(date);
}
if (unit === 'm') {
if (isCurrent) {
if (minusCurrent) {
const prev = new Date(now);
prev.setMinutes(prev.getMinutes() - 1);
return end ? endOfMinute(prev) : startOfMinute(now);
}
return end ? endOfMinute(now) : startOfMinute(now);
}
const date = new Date(now);
date.setMinutes(date.getMinutes() + amount);
return end ? endOfMinute(date) : startOfMinute(date);
}
return null;
}
function parseRelativeDateOperand(text, boundary = 'start') {
const raw = String(text).trim();
if (!raw) return null;
if (/^TODAY$/i.test(raw)) {
const now = new Date();
return boundary === 'end' ? endOfDay(now) : startOfDay(now);
}
const offset = raw.match(RELATIVE_OFFSET_RE);
if (offset) {
const amount = (offset[1] === '-' ? -1 : 1) * Number(offset[2]);
return resolveRelativeUnit(
new Date(),
normalizeRelativeUnit(offset[3]),
amount,
boundary,
false,
false
);
}
const current = raw.match(RELATIVE_CURRENT_RE);
if (current) {
return resolveRelativeUnit(
new Date(),
normalizeRelativeUnit(current[2]),
0,
boundary,
true,
current[1] === '-'
);
}
return null;
}
function dateComponentsMatch(date, year, month, day, hour, minute, second) {
return (
date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day &&
date.getHours() === hour &&
date.getMinutes() === minute &&
date.getSeconds() === second
);
}
// Parses a single date operand to a Date, filling missing parts from the
// current date and the supplied interval boundary ('start' | 'end').
function parseDateOperand(value, boundary = 'start') {
@ -341,6 +582,9 @@ function parseDateOperand(value, boundary = 'start') {
if (!text) return null;
const end = boundary === 'end';
const relative = parseRelativeDateOperand(text, boundary);
if (relative) return relative;
// Values with explicit separators (ISO and similar) are parsed directly.
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
const parsed = new Date(text);
@ -364,7 +608,46 @@ function parseDateOperand(value, boundary = 'start') {
const second = nums.length >= 6 ? nums[5] : end ? 59 : 0;
const ms = end ? 999 : 0;
const date = new Date(year, month - 1, day, hour, minute, second, ms);
return isNaN(date.getTime()) ? null : date;
if (isNaN(date.getTime())) return null;
if (!dateComponentsMatch(date, year, month, day, hour, minute, second)) return null;
return date;
}
function isValidDateOperand(value) {
return parseDateOperand(value, 'start') != null;
}
function isValidDateFilterLeaf(rawToken) {
const token = rawToken.trim();
if (token === '') return true;
const rangeIdx = token.indexOf('..');
if (rangeIdx !== -1) {
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
if (lo !== '' && !isValidDateOperand(lo)) return false;
if (hi !== '' && !isValidDateOperand(hi)) return false;
return true;
}
const operators = ['<>', '>=', '<=', '>', '<', '='];
for (const symbol of operators) {
if (token.startsWith(symbol)) {
const operand = stripIgnoreCase(token.slice(symbol.length).trim());
return operand !== '' && isValidDateOperand(operand);
}
}
return isValidDateOperand(stripIgnoreCase(token));
}
function isValidDateFilterNode(node) {
if (node.type === 'leaf') return isValidDateFilterLeaf(node.token);
return node.items.every(isValidDateFilterNode);
}
function isValidDateFilterExpression(expression) {
return isValidDateFilterNode(parseExpression(String(expression)));
}
// Coerces a literal to its strongest matching scalar type.
@ -380,8 +663,7 @@ function coerceScalar(value) {
// Coerces an interval/comparison boundary, preferring a date for date fields.
function coerceBoundary(value, isDateField, boundary) {
if (isDateField) {
const date = parseDateOperand(value, boundary);
if (date) return date;
return parseDateOperand(value, boundary);
}
return coerceScalar(value);
}
@ -462,6 +744,7 @@ function buildEquality(
const start = parseDateOperand(value, 'start');
const end = parseDateOperand(value, 'end');
if (start && end) return { op: { $gte: start, $lte: end } };
return NO_MATCH_CONDITION;
}
const lower = value.toLowerCase();
if (lower === 'true') return { value: true };
@ -506,6 +789,7 @@ function buildComparison(
const start = parseDateOperand(value, 'start');
const end = parseDateOperand(value, 'end');
if (start && end) return { op: { $not: { $gte: start, $lte: end } } };
return NO_MATCH_CONDITION;
}
if (/[*?]/.test(value)) {
if (isNumberField) {
@ -521,7 +805,9 @@ function buildComparison(
// For dates, < and >= align to the start of the day, > and <= to the end.
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start';
return { op: { [`$${name}`]: coerceBoundary(value, isDateField, boundary) } };
const bound = coerceBoundary(value, isDateField, boundary);
if (isDateField && !bound) return NO_MATCH_CONDITION;
return { op: { [`$${name}`]: bound } };
}
function parseLeafComparisonOperators(
@ -582,6 +868,10 @@ function parseLeafCondition(
return NO_MATCH_CONDITION;
}
}
if (isDateField) {
if (lo !== '' && !parseDateOperand(lo, 'start')) return NO_MATCH_CONDITION;
if (hi !== '' && !parseDateOperand(hi, 'end')) return NO_MATCH_CONDITION;
}
const op = {};
if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start');
if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end');
@ -882,10 +1172,13 @@ async function parseFilter(property, value, model = null) {
expression = stripModelPrefixFromExpression(expression);
}
const isDateField = looksLikeDateField(property);
const isDateField = isDateTimeField(property, model);
const isObjectIdField = fieldKind.kind === 'objectId';
const isNumField = isNumberField(filterProperty, model);
const tree = parseExpression(expression);
if (isDateField && !isValidDateFilterNode(tree)) {
return {};
}
const condition = buildCondition(
tree,
filterProperty,
@ -1803,7 +2096,15 @@ async function getFilter(query, allowedFilters, parse = true, model = null) {
}
for (const [key, value] of Object.entries(queryClean)) {
if (allowedFilters.includes(key)) {
clauses.push(parse ? await parseFilter(key, value, model) : { [key]: value });
if (parse) {
const clause = await parseFilter(key, value, model);
// Drop date filters that contain anything other than operators and dates.
if (clause && Object.keys(clause).length > 0) {
clauses.push(clause);
}
} else {
clauses.push({ [key]: value });
}
}
}
return mergeFilterClauses(clauses);