All files / server gap-filler.ts

92.96% Statements 119/128
91.66% Branches 66/72
100% Functions 12/12
92.8% Lines 116/125

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342                                                                60x 60x 60x     60x   60x 13974x 13974x     60x             120x   120x               4x 4x   78x 78x     10x 10x 10x 10x 10x     18x 18x 18x   6x 6x 6x 6x     4x 4x 4x     120x             13974x   13974x               151x 151x   13482x 13482x   131x 131x   194x 194x   10x 10x   6x 6x     13974x             14603x 13886x   717x   709x 709x 709x     8x                   14603x 961x   34213x                           53x     53x   53x         53x   53x 14603x 14603x   14603x 200x   14603x       53x 5x       53x   53x   205x       205x 148959x 148959x   148959x   13895x     135064x         135064x 135036x 280154x         135064x 673845x     135064x         53x               77x 1x     76x 47x 1x   46x 46x   46x 1x     45x         29x 29x   5x         24x                                 845x 713x       132x   139x     139x   139x     132x 60x       72x     845x 845x         845x   845x 72x   72x 24x     48x                 48x     72x    
/**
 * Gap Filler Utility
 * Fills missing time series gaps in query results to ensure continuous data for charts.
 * Follows Cube.js naming conventions (fillMissingDates) but implements server-side.
 */
 
import type { SemanticQuery } from './types/query'
import type { TimeGranularity } from './types/core'
 
export interface GapFillerConfig {
  /** The time dimension key (e.g., 'Sales.date') */
  timeDimensionKey: string
  /** Time granularity for bucket generation */
  granularity: TimeGranularity
  /** Date range [start, end] */
  dateRange: [Date, Date]
  /** Value to fill for missing measures (default: 0) */
  fillValue: number | null
  /** List of measure keys in the data */
  measures: string[]
  /** List of dimension keys in the data (excluding time dimensions) */
  dimensions: string[]
}
 
/**
 * Generate all time buckets for a given date range and granularity
 */
export function generateTimeBuckets(
  startDate: Date,
  endDate: Date,
  granularity: TimeGranularity
): Date[] {
  const buckets: Date[] = []
  let current = alignToGranularity(new Date(startDate), granularity)
  const end = alignToGranularity(new Date(endDate), granularity)
 
  // Safety: limit to prevent infinite loops (max 10,000 buckets)
  const maxBuckets = 10000
 
  while (current <= end && buckets.length < maxBuckets) {
    buckets.push(new Date(current))
    current = incrementByGranularity(current, granularity)
  }
 
  return buckets
}
 
/**
 * Align a date to the start of its granularity bucket
 */
function alignToGranularity(date: Date, granularity: TimeGranularity): Date {
  const aligned = new Date(date)
 
  switch (granularity) {
    case 'second':
      aligned.setUTCMilliseconds(0)
      break
    case 'minute':
      aligned.setUTCSeconds(0, 0)
      break
    case 'hour':
      aligned.setUTCMinutes(0, 0, 0)
      break
    case 'day':
      aligned.setUTCHours(0, 0, 0, 0)
      break
    case 'week': {
      // Align to Monday (ISO week start)
      const dayOfWeek = aligned.getUTCDay()
      const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
      aligned.setUTCDate(aligned.getUTCDate() - daysToMonday)
      aligned.setUTCHours(0, 0, 0, 0)
      break
    }
    case 'month':
      aligned.setUTCDate(1)
      aligned.setUTCHours(0, 0, 0, 0)
      break
    case 'quarter': {
      const quarterMonth = Math.floor(aligned.getUTCMonth() / 3) * 3
      aligned.setUTCMonth(quarterMonth, 1)
      aligned.setUTCHours(0, 0, 0, 0)
      break
    }
    case 'year':
      aligned.setUTCMonth(0, 1)
      aligned.setUTCHours(0, 0, 0, 0)
      break
  }
 
  return aligned
}
 
/**
 * Increment a date by one granularity unit
 */
function incrementByGranularity(date: Date, granularity: TimeGranularity): Date {
  const next = new Date(date)
 
  switch (granularity) {
    case 'second':
      next.setUTCSeconds(next.getUTCSeconds() + 1)
      break
    case 'minute':
      next.setUTCMinutes(next.getUTCMinutes() + 1)
      break
    case 'hour':
      next.setUTCHours(next.getUTCHours() + 1)
      break
    case 'day':
      next.setUTCDate(next.getUTCDate() + 1)
      break
    case 'week':
      next.setUTCDate(next.getUTCDate() + 7)
      break
    case 'month':
      next.setUTCMonth(next.getUTCMonth() + 1)
      break
    case 'quarter':
      next.setUTCMonth(next.getUTCMonth() + 3)
      break
    case 'year':
      next.setUTCFullYear(next.getUTCFullYear() + 1)
      break
  }
 
  return next
}
 
/**
 * Normalize a date value to ISO string for consistent comparison
 */
function normalizeDateKey(value: unknown): string {
  if (value instanceof Date) {
    return value.toISOString()
  }
  if (typeof value === 'string') {
    // Parse and re-format for consistency
    const date = new Date(value)
    Eif (!isNaN(date.getTime())) {
      return date.toISOString()
    }
  }
  return String(value)
}
 
/**
 * Create a dimension group key for grouping rows by dimension values
 */
function createDimensionGroupKey(
  row: Record<string, unknown>,
  dimensions: string[]
): string {
  if (dimensions.length === 0) {
    return '__all__'
  }
  return dimensions.map(dim => String(row[dim] ?? '')).join('|||')
}
 
/**
 * Fill time series gaps in query result data
 *
 * @param data - Original query result data
 * @param config - Gap filling configuration
 * @returns Data with gaps filled
 */
export function fillTimeSeriesGaps(
  data: Record<string, unknown>[],
  config: GapFillerConfig
): Record<string, unknown>[] {
  const { timeDimensionKey, granularity, dateRange, fillValue, measures, dimensions } = config
 
  // Generate all expected time buckets
  const timeBuckets = generateTimeBuckets(dateRange[0], dateRange[1], granularity)
 
  Iif (timeBuckets.length === 0) {
    return data
  }
 
  // Group data by dimension values
  const dimensionGroups = new Map<string, Map<string, Record<string, unknown>>>()
 
  for (const row of data) {
    const groupKey = createDimensionGroupKey(row, dimensions)
    const timeKey = normalizeDateKey(row[timeDimensionKey])
 
    if (!dimensionGroups.has(groupKey)) {
      dimensionGroups.set(groupKey, new Map())
    }
    dimensionGroups.get(groupKey)!.set(timeKey, row)
  }
 
  // If no data at all, create one group with no dimensions
  if (dimensionGroups.size === 0 && dimensions.length === 0) {
    dimensionGroups.set('__all__', new Map())
  }
 
  // Build filled result
  const result: Record<string, unknown>[] = []
 
  for (const [_groupKey, timeMap] of dimensionGroups) {
    // Get a sample row from this group to extract dimension values
    const sampleRow = timeMap.size > 0
      ? timeMap.values().next().value
      : null
 
    for (const bucket of timeBuckets) {
      const bucketKey = bucket.toISOString()
      const existingRow = timeMap.get(bucketKey)
 
      if (existingRow) {
        // Use existing row
        result.push(existingRow)
      } else {
        // Create filled row
        const filledRow: Record<string, unknown> = {
          [timeDimensionKey]: bucketKey
        }
 
        // Copy dimension values from sample row
        if (sampleRow) {
          for (const dim of dimensions) {
            filledRow[dim] = sampleRow[dim]
          }
        }
 
        // Fill measures with fill value
        for (const measure of measures) {
          filledRow[measure] = fillValue
        }
 
        result.push(filledRow)
      }
    }
  }
 
  return result
}
 
/**
 * Parse date range from query time dimension
 * Handles both array format ['2024-01-01', '2024-01-31'] and string format
 */
export function parseDateRange(dateRange: string | string[] | undefined): [Date, Date] | null {
  if (!dateRange) {
    return null
  }
 
  if (Array.isArray(dateRange)) {
    if (dateRange.length < 2) {
      return null
    }
    const start = new Date(dateRange[0])
    const end = new Date(dateRange[1])
 
    if (isNaN(start.getTime()) || isNaN(end.getTime())) {
      return null
    }
 
    return [start, end]
  }
 
  // Handle relative date ranges (e.g., 'last 7 days', 'this week')
  // For now, just try to parse as a single date string
  const date = new Date(dateRange)
  if (!isNaN(date.getTime())) {
    // Single date - return same day range
    return [date, date]
  }
 
  // Relative date strings would need more complex parsing
  // For now, return null to skip gap filling
  return null
}
 
/**
 * Apply gap filling to query result data based on query configuration
 *
 * @param data - Original query result data
 * @param query - The semantic query
 * @param measures - List of measure names in the result
 * @returns Data with gaps filled (if applicable)
 */
export function applyGapFilling(
  data: Record<string, unknown>[],
  query: SemanticQuery,
  measures: string[]
): Record<string, unknown>[] {
  // Check if we have time dimensions to fill
  if (!query.timeDimensions || query.timeDimensions.length === 0) {
    return data
  }
 
  // Find time dimensions that need gap filling
  const timeDimensionsToFill = query.timeDimensions.filter(td => {
    // fillMissingDates defaults to true
    const shouldFill = td.fillMissingDates !== false
 
    // Must have granularity and dateRange to fill gaps
    const canFill = td.granularity && td.dateRange
 
    return shouldFill && canFill
  })
 
  if (timeDimensionsToFill.length === 0) {
    return data
  }
 
  // Get fill value (default: 0, but allow explicit null)
  const fillValue = query.fillMissingDatesValue === undefined ? 0 : query.fillMissingDatesValue
 
  // Get regular dimensions (exclude time dimensions)
  const timeDimensionKeys = new Set(query.timeDimensions.map(td => td.dimension))
  const regularDimensions = (query.dimensions || []).filter(d => !timeDimensionKeys.has(d))
 
  // Apply gap filling for each time dimension
  // Note: Currently only supports single time dimension gap filling
  // Multiple time dimensions would require more complex logic
  let result = data
 
  for (const timeDim of timeDimensionsToFill) {
    const dateRange = parseDateRange(timeDim.dateRange)
 
    if (!dateRange) {
      continue
    }
 
    const config: GapFillerConfig = {
      timeDimensionKey: timeDim.dimension,
      granularity: timeDim.granularity!,
      dateRange,
      fillValue,
      measures,
      dimensions: regularDimensions
    }
 
    result = fillTimeSeriesGaps(result, config)
  }
 
  return result
}