All files server/template-substitution.ts

84.7% Statements 72/85
68.42% Branches 26/38
66.66% Functions 4/6
85.36% Lines 70/82

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                                                                                            42x     42x     42x   42x 78x     78x 78x   78x             78x 78x   78x                   78x 78x   78x             42x 42x 42x     42x 78x 78x   78x   78x     78x 78x 78x     78x         42x       42x           42x   42x   120x 92x       120x 78x         42x                                           126x 126x 126x   126x 219x   219x                   219x               126x                         45x     45x 45x 975x 69x 906x 66x 66x             45x 3x       45x 45x         45x 45x         45x 45x 66x     66x               66x             45x                                             39x 39x   39x 75x 75x 75x     39x    
/**
 * Template Substitution Engine
 *
 * Handles substitution of {member} references in calculatedSql templates
 * with actual SQL expressions while maintaining Drizzle type safety.
 */
 
import { sql, SQL, StringChunk } from 'drizzle-orm'
import type { Cube, QueryContext } from './types/cube'
 
/**
 * Resolved measure SQL builders
 * Maps full measure names (e.g., "Cube.measure") to functions that build their SQL
 * Using functions instead of SQL objects avoids mutation and shared reference issues
 */
export type ResolvedMeasures = Map<string, () => SQL>
 
/**
 * Substitution context
 */
export interface SubstitutionContext {
  /** The cube being processed */
  cube: Cube
  /** All available cubes for cross-cube references */
  allCubes: Map<string, Cube>
  /** Already resolved measure SQL expressions */
  resolvedMeasures: ResolvedMeasures
  /** Query context for SQL generation */
  queryContext: QueryContext
}
 
/**
 * Substitute {member} references in calculatedSql template
 *
 * Replaces {member} with the corresponding SQL expression from resolvedMeasures.
 * Supports both same-cube ({measure}) and cross-cube ({Cube.measure}) references.
 *
 * @param calculatedSql - Template string with {member} references
 * @param context - Substitution context
 * @returns SQL expression with substituted values
 * @throws Error if referenced measure is not resolved
 */
export function substituteTemplate(
  calculatedSql: string,
  context: SubstitutionContext
): SQL {
  const { cube, allCubes, resolvedMeasures } = context
 
  // Extract all {member} references
  const memberRefs = extractMemberReferences(calculatedSql)
 
  // Build substitution map (maps member names to their SQL expressions)
  const substitutions = new Map<string, SQL>()
 
  for (const ref of memberRefs) {
    const { originalRef, cubeName, fieldName } = ref
 
    // Resolve cube and measure name
    const targetCubeName = cubeName || cube.name
    const targetCube = allCubes.get(targetCubeName)
 
    Iif (!targetCube) {
      throw new Error(
        `Cannot substitute {${originalRef}}: cube '${targetCubeName}' not found`
      )
    }
 
    // Get resolved SQL builder for the measure
    const fullMeasureName = `${targetCubeName}.${fieldName}`
    const resolvedBuilder = resolvedMeasures.get(fullMeasureName)
 
    Iif (!resolvedBuilder) {
      throw new Error(
        `Cannot substitute {${originalRef}}: measure '${fullMeasureName}' not resolved yet. ` +
        `Ensure measures are resolved in dependency order.`
      )
    }
 
    // Call the builder function to get a fresh SQL object
    // Single wrap is OK here - each builder() call creates fresh SQL that won't be reused
    // The builder functions themselves handle isolation via resolveSqlExpression()
    const resolvedSql = resolvedBuilder()
    const wrappedSql = sql`${resolvedSql}`
 
    substitutions.set(originalRef, wrappedSql)
  }
 
  // Build SQL expression by parsing the template and substituting SQL expressions
  // We need to build a single sql`` template literal with the substituted expressions
 
  // Build arrays for template parts and values
  const sqlParts: string[] = []
  const sqlValues: SQL[] = []
  let lastIndex = 0
 
  // Find all {member} references and build template parts
  for (const ref of memberRefs) {
    const pattern = `{${ref.originalRef}}`
    const index = calculatedSql.indexOf(pattern, lastIndex)
 
    Eif (index >= 0) {
      // Add the string part before this reference
      sqlParts.push(calculatedSql.substring(lastIndex, index))
 
      // Add the SQL expression as a value
      const resolvedSql = substitutions.get(ref.originalRef)
      Eif (resolvedSql) {
        sqlValues.push(resolvedSql)
      }
 
      lastIndex = index + pattern.length
    }
  }
 
  // Add any remaining string part
  sqlParts.push(calculatedSql.substring(lastIndex))
 
  // Build the final SQL expression
  // If no member references, return raw SQL
  Iif (sqlValues.length === 0) {
    return sql.raw(calculatedSql)
  }
 
  // Build SQL using Drizzle's sql.join to avoid mutation
  // Keep SQL objects intact instead of spreading their chunks
  const parts: (StringChunk | SQL)[] = []
 
  for (let i = 0; i < sqlParts.length; i++) {
    // Add string part if non-empty
    if (sqlParts[i]) {
      parts.push(new StringChunk(sqlParts[i]))
    }
 
    // Add SQL value (if any)
    if (i < sqlValues.length) {
      parts.push(sqlValues[i])
    }
  }
 
  // Use sql.join with empty separator to concatenate
  return sql.join(parts, sql.raw(''))
}
 
/**
 * Member reference extracted from template
 */
interface MemberReference {
  /** Original reference as it appears in template (e.g., "measure" or "Cube.measure") */
  originalRef: string
  /** Cube name if cross-cube reference, null otherwise */
  cubeName: string | null
  /** Field/measure name */
  fieldName: string
}
 
/**
 * Extract all {member} references from calculatedSql template
 *
 * @param calculatedSql - Template string
 * @returns Array of member references
 */
function extractMemberReferences(calculatedSql: string): MemberReference[] {
  const regex = /\{([^}]+)\}/g
  const matches = calculatedSql.matchAll(regex)
  const references: MemberReference[] = []
 
  for (const match of matches) {
    const memberRef = match[1].trim()
 
    Iif (memberRef.includes('.')) {
      // Cross-cube reference: {Cube.measure}
      const [cubeName, fieldName] = memberRef.split('.').map(s => s.trim())
      references.push({
        originalRef: memberRef,
        cubeName,
        fieldName
      })
    } else {
      // Same-cube reference: {measure}
      references.push({
        originalRef: memberRef,
        cubeName: null,
        fieldName: memberRef
      })
    }
  }
 
  return references
}
 
/**
 * Validate calculatedSql template syntax
 *
 * @param calculatedSql - Template string to validate
 * @returns Validation result
 */
export function validateTemplateSyntax(calculatedSql: string): {
  isValid: boolean
  errors: string[]
} {
  const errors: string[] = []
 
  // Check for unmatched braces
  let braceDepth = 0
  for (let i = 0; i < calculatedSql.length; i++) {
    if (calculatedSql[i] === '{') {
      braceDepth++
    } else if (calculatedSql[i] === '}') {
      braceDepth--
      Iif (braceDepth < 0) {
        errors.push(`Unmatched closing brace at position ${i}`)
        break
      }
    }
  }
 
  if (braceDepth > 0) {
    errors.push('Unmatched opening brace in template')
  }
 
  // Check for empty references
  const emptyRefRegex = /\{\s*\}/
  Iif (emptyRefRegex.test(calculatedSql)) {
    errors.push('Empty member reference {} found in template')
  }
 
  // Check for nested braces
  const nestedBraceRegex = /\{[^}]*\{/
  Iif (nestedBraceRegex.test(calculatedSql)) {
    errors.push('Nested braces are not allowed in member references')
  }
 
  // Check for invalid characters in member names
  const memberRefs = extractMemberReferences(calculatedSql)
  for (const ref of memberRefs) {
    const fullRef = ref.cubeName ? `${ref.cubeName}.${ref.fieldName}` : ref.fieldName
 
    // Allow alphanumeric, underscore, and dot
    Iif (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(fullRef)) {
      errors.push(
        `Invalid member reference {${ref.originalRef}}: must start with letter or underscore, ` +
        `and contain only letters, numbers, underscores, and dots`
      )
    }
 
    // Check for multiple dots (only Cube.measure allowed)
    Iif (fullRef.split('.').length > 2) {
      errors.push(
        `Invalid member reference {${ref.originalRef}}: only one dot allowed (Cube.measure format)`
      )
    }
  }
 
  return {
    isValid: errors.length === 0,
    errors
  }
}
 
/**
 * Check if a template contains any member references
 *
 * @param calculatedSql - Template string to check
 * @returns True if template contains {member} references
 */
export function hasMemberReferences(calculatedSql: string): boolean {
  return /\{[^}]+\}/.test(calculatedSql)
}
 
/**
 * Get list of all unique member references in a template
 *
 * @param calculatedSql - Template string
 * @returns Array of unique full member names (e.g., ["Cube.measure", "otherMeasure"])
 */
export function getMemberReferences(calculatedSql: string, currentCube: string): string[] {
  const refs = extractMemberReferences(calculatedSql)
  const uniqueRefs = new Set<string>()
 
  for (const ref of refs) {
    const cubeName = ref.cubeName || currentCube
    const fullName = `${cubeName}.${ref.fieldName}`
    uniqueRefs.add(fullName)
  }
 
  return Array.from(uniqueRefs)
}