@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
467 lines • 16.4 kB
JavaScript
/**
* Date/Time Function Handler - Phase 4B Implementation
*
* Enables all time-based analytics queries with SQLite date functions.
* Supports relative dates, date ranges, and time arithmetic.
*
* User Requirement: "experiments that were started in the last 30 days"
*
* Features:
* - Relative date parsing (LAST_30_DAYS, THIS_MONTH, etc.)
* - Date range filtering (BETWEEN dates)
* - Year/month/day filtering
* - SQLite native date functions for optimal performance
*/
import { getLogger } from '../../logging/Logger.js';
const logger = getLogger();
export class DateFunctionHandler {
DATE_FUNCTIONS = {
// Relative date functions
'LAST_30_DAYS': `DATE('now', '-30 days')`,
'LAST_7_DAYS': `DATE('now', '-7 days')`,
'LAST_1_DAY': `DATE('now', '-1 day')`,
'LAST_90_DAYS': `DATE('now', '-90 days')`,
'LAST_365_DAYS': `DATE('now', '-365 days')`,
'LAST_1_YEAR': `DATE('now', '-1 year')`,
// Period functions
'TODAY': `DATE('now')`,
'YESTERDAY': `DATE('now', '-1 day')`,
'THIS_WEEK': `DATE('now', 'weekday 0', '-6 days')`,
'THIS_MONTH': `DATE('now', 'start of month')`,
'THIS_YEAR': `DATE('now', 'start of year')`,
'LAST_WEEK': `DATE('now', 'weekday 0', '-13 days')`,
'LAST_MONTH': `DATE('now', 'start of month', '-1 month')`,
'LAST_YEAR': `DATE('now', 'start of year', '-1 year')`,
// Start/end of periods
'START_OF_MONTH': `DATE('now', 'start of month')`,
'END_OF_MONTH': `DATE('now', 'start of month', '+1 month', '-1 day')`,
'START_OF_YEAR': `DATE('now', 'start of year')`,
'END_OF_YEAR': `DATE('now', 'start of year', '+1 year', '-1 day')`,
'START_OF_WEEK': `DATE('now', 'weekday 0', '-6 days')`,
'END_OF_WEEK': `DATE('now', 'weekday 0')`
};
TIME_PATTERNS = {
// ISO 8601 date formats
ISO_DATE: /^\d{4}-\d{2}-\d{2}$/,
ISO_DATETIME: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
// Relative date patterns
LAST_N_DAYS: /^LAST_(\d+)_DAYS?$/i,
LAST_N_HOURS: /^LAST_(\d+)_HOURS?$/i,
LAST_N_MONTHS: /^LAST_(\d+)_MONTHS?$/i,
LAST_N_YEARS: /^LAST_(\d+)_YEARS?$/i,
// Numeric patterns
YEAR_ONLY: /^\d{4}$/,
MONTH_YEAR: /^\d{4}-\d{2}$/
};
constructor() {
logger.info('DateFunctionHandler initialized with SQLite date functions');
}
/**
* Parse date filter condition and convert to SQL
*/
parseDateFilter(condition) {
try {
const { field, operator, value } = condition;
logger.debug(`Parsing date filter: ${field} ${operator} ${value}`);
// Handle different operator types
switch (operator) {
case 'BETWEEN':
return this.handleBetweenOperator(field, value);
case '>':
case '>=':
case '<':
case '<=':
case '=':
return this.handleComparisonOperator(field, operator, value);
default:
return this.handleSpecialOperators(field, operator, value);
}
}
catch (error) {
logger.error(`Date filter parsing failed: ${error instanceof Error ? error.message : String(error)}`);
return {
sqlExpression: '',
isValid: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Handle BETWEEN operator for date ranges
*/
handleBetweenOperator(field, value) {
if (!Array.isArray(value) || value.length !== 2) {
return {
sqlExpression: '',
isValid: false,
error: 'BETWEEN operator requires array with exactly 2 values'
};
}
const [startDate, endDate] = value;
const startSQL = this.convertToSQLDate(startDate);
const endSQL = this.convertToSQLDate(endDate);
if (!startSQL.isValid || !endSQL.isValid) {
return {
sqlExpression: '',
isValid: false,
error: `Invalid date values: ${startSQL.error || endSQL.error}`
};
}
const sqlExpression = `${field} BETWEEN ${startSQL.sqlExpression} AND ${endSQL.sqlExpression}`;
return {
sqlExpression,
isValid: true,
parsedValue: [startDate, endDate]
};
}
/**
* Handle comparison operators (>, <, >=, <=, =)
*/
handleComparisonOperator(field, operator, value) {
const dateSQL = this.convertToSQLDate(value);
if (!dateSQL.isValid) {
return {
sqlExpression: '',
isValid: false,
error: dateSQL.error
};
}
const sqlExpression = `${field} ${operator} ${dateSQL.sqlExpression}`;
return {
sqlExpression,
isValid: true,
parsedValue: value
};
}
/**
* Handle special date operators (YEAR, MONTH, etc.)
*/
handleSpecialOperators(field, operator, value) {
switch (operator) {
case 'YEAR':
return this.handleYearFilter(field, value);
case 'MONTH':
return this.handleMonthFilter(field, value);
case 'DAY':
return this.handleDayFilter(field, value);
case 'LAST_N_DAYS':
return this.handleLastNDays(field, value);
default:
return {
sqlExpression: '',
isValid: false,
error: `Unsupported date operator: ${operator}`
};
}
}
/**
* Validate a date string in YYYY-MM-DD format
*/
isValidDate(dateStr) {
const parts = dateStr.split('-');
if (parts.length !== 3)
return false;
const year = parseInt(parts[0]);
const month = parseInt(parts[1]);
const day = parseInt(parts[2]);
// Validate month
if (month < 1 || month > 12)
return false;
// Days in each month (non-leap year)
const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Check for leap year
const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
if (isLeapYear && month === 2) {
daysInMonth[1] = 29;
}
// Validate day
if (day < 1 || day > daysInMonth[month - 1])
return false;
return true;
}
/**
* Convert value to SQL date expression
*/
convertToSQLDate(value) {
if (typeof value === 'string') {
// Check if it's a predefined function
if (this.DATE_FUNCTIONS[value.toUpperCase()]) {
return {
sqlExpression: this.DATE_FUNCTIONS[value.toUpperCase()],
isValid: true,
parsedValue: value
};
}
// Check for dynamic patterns
const lastNDaysMatch = value.match(this.TIME_PATTERNS.LAST_N_DAYS);
if (lastNDaysMatch) {
const days = parseInt(lastNDaysMatch[1]);
return {
sqlExpression: `DATE('now', '-${days} days')`,
isValid: true,
parsedValue: value
};
}
const lastNHoursMatch = value.match(this.TIME_PATTERNS.LAST_N_HOURS);
if (lastNHoursMatch) {
const hours = parseInt(lastNHoursMatch[1]);
return {
sqlExpression: `DATETIME('now', '-${hours} hours')`,
isValid: true,
parsedValue: value
};
}
const lastNMonthsMatch = value.match(this.TIME_PATTERNS.LAST_N_MONTHS);
if (lastNMonthsMatch) {
const months = parseInt(lastNMonthsMatch[1]);
return {
sqlExpression: `DATE('now', '-${months} months')`,
isValid: true,
parsedValue: value
};
}
// Check for ISO date formats with validation
if (this.TIME_PATTERNS.ISO_DATE.test(value)) {
if (!this.isValidDate(value)) {
return {
sqlExpression: '',
isValid: false,
error: `Invalid date: ${value}`
};
}
return {
sqlExpression: `'${value}'`,
isValid: true,
parsedValue: value
};
}
if (this.TIME_PATTERNS.ISO_DATETIME.test(value)) {
// Extract date part and validate
const datePart = value.split('T')[0];
if (!this.isValidDate(datePart)) {
return {
sqlExpression: '',
isValid: false,
error: `Invalid datetime: ${value}`
};
}
return {
sqlExpression: `'${value}'`,
isValid: true,
parsedValue: value
};
}
// Check for year-only format
if (this.TIME_PATTERNS.YEAR_ONLY.test(value)) {
return {
sqlExpression: `'${value}-01-01'`,
isValid: true,
parsedValue: value
};
}
// Check for month-year format
if (this.TIME_PATTERNS.MONTH_YEAR.test(value)) {
return {
sqlExpression: `'${value}-01'`,
isValid: true,
parsedValue: value
};
}
}
if (typeof value === 'number') {
// Assume it's a year if it's a 4-digit number
if (value >= 1000 && value <= 9999) {
return {
sqlExpression: `'${value}-01-01'`,
isValid: true,
parsedValue: value
};
}
}
return {
sqlExpression: '',
isValid: false,
error: `Cannot convert value to SQL date: ${value}`
};
}
/**
* Handle YEAR filtering
*/
handleYearFilter(field, value) {
const year = typeof value === 'string' ? parseInt(value) : value;
if (isNaN(year) || year < 1000 || year > 9999) {
return {
sqlExpression: '',
isValid: false,
error: 'Year must be a 4-digit number'
};
}
const sqlExpression = `STRFTIME('%Y', ${field}) = '${year}'`;
return {
sqlExpression,
isValid: true,
parsedValue: year
};
}
/**
* Handle MONTH filtering
*/
handleMonthFilter(field, value) {
const month = typeof value === 'string' ? parseInt(value) : value;
if (isNaN(month) || month < 1 || month > 12) {
return {
sqlExpression: '',
isValid: false,
error: 'Month must be a number between 1 and 12'
};
}
const monthStr = month.toString().padStart(2, '0');
const sqlExpression = `STRFTIME('%m', ${field}) = '${monthStr}'`;
return {
sqlExpression,
isValid: true,
parsedValue: month
};
}
/**
* Handle DAY filtering
*/
handleDayFilter(field, value) {
const day = typeof value === 'string' ? parseInt(value) : value;
if (isNaN(day) || day < 1 || day > 31) {
return {
sqlExpression: '',
isValid: false,
error: 'Day must be a number between 1 and 31'
};
}
const dayStr = day.toString().padStart(2, '0');
const sqlExpression = `STRFTIME('%d', ${field}) = '${dayStr}'`;
return {
sqlExpression,
isValid: true,
parsedValue: day
};
}
/**
* Handle LAST_N_DAYS operator
*/
handleLastNDays(field, value) {
const days = typeof value === 'string' ? parseInt(value) : value;
if (isNaN(days) || days < 1) {
return {
sqlExpression: '',
isValid: false,
error: 'Days must be a positive number'
};
}
const sqlExpression = `${field} >= DATE('now', '-${days} days')`;
return {
sqlExpression,
isValid: true,
parsedValue: days
};
}
/**
* Validate date value format
*/
validateDateValue(value) {
const result = this.convertToSQLDate(value);
return {
isValid: result.isValid,
error: result.error
};
}
/**
* Get available date functions
*/
getAvailableFunctions() {
return Object.keys(this.DATE_FUNCTIONS);
}
/**
* Get SQL for relative date
*/
getRelativeDateSQL(relativeDate) {
return this.DATE_FUNCTIONS[relativeDate.toUpperCase()] || null;
}
/**
* Check if a field appears to be a date field
*/
isDateField(fieldName) {
const dateFieldPatterns = [
/created/i,
/updated/i,
/modified/i,
/time/i,
/date/i,
/start/i,
/end/i,
/earliest/i,
/latest/i
];
return dateFieldPatterns.some(pattern => pattern.test(fieldName));
}
/**
* Get example queries for documentation
*/
getExampleQueries() {
return [
{
description: 'Experiments started in the last 30 days',
query: {
find: 'experiments',
select: ['name', 'created_time'],
where: [{ field: 'created_time', operator: '>', value: 'LAST_30_DAYS' }]
}
},
{
description: 'Flags created this month',
query: {
find: 'flags',
select: ['key', 'name', 'created_time'],
where: [{ field: 'created_time', operator: '>=', value: 'THIS_MONTH' }]
}
},
{
description: 'Experiments in date range',
query: {
find: 'experiments',
select: ['name', 'start_date'],
where: [{ field: 'start_date', operator: 'BETWEEN', value: ['2025-01-01', '2025-01-31'] }]
}
},
{
description: 'Flags created in 2025',
query: {
find: 'flags',
select: ['key', 'name'],
where: [{ field: 'created_time', operator: 'YEAR', value: 2025 }]
}
},
{
description: 'Recent activity (last 7 days)',
query: {
find: 'experiments',
select: ['name', 'last_modified'],
where: [{ field: 'last_modified', operator: '>', value: 'LAST_7_DAYS' }],
orderBy: [{ field: 'last_modified', direction: 'DESC' }]
}
}
];
}
/**
* Get statistics about date function usage
*/
getStatistics() {
const functionKeys = Object.keys(this.DATE_FUNCTIONS);
const relativeFunctions = functionKeys.filter(key => key.includes('LAST_')).length;
const periodFunctions = functionKeys.filter(key => key.includes('THIS_') || key.includes('START_') || key.includes('END_')).length;
return {
totalFunctions: functionKeys.length,
relativeFunctions,
periodFunctions,
supportedPatterns: Object.keys(this.TIME_PATTERNS).length
};
}
}
//# sourceMappingURL=DateFunctionHandler.js.map