or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

api

features

charts

charts.mdconditional-formatting.mdvisualizations.md
authorization.mdchangesets.mdcharts-as-code.mdcompiler.mddashboards.mddbt.mdee-features.mdformatting.mdparameters.mdpivot.mdprojects-spaces.mdsql-runner.mdtemplating.mdwarehouse.md
index.md
tile.json

dependency-graph.mddocs/api/utilities/specialized/

Dependency Graph Utilities

Utilities for detecting circular dependencies in dependency graphs, useful for validating table calculations, metrics, and custom dimensions.

Capabilities

This module provides the following functionality:

Core Types and Functions

/**
 * Represents a node in a dependency graph
 */
interface DependencyNode {
  name: string;
  dependencies: string[];
}

/**
 * Detects circular dependencies in a dependency graph using depth-first search.
 * Throws an error if a circular dependency is found.
 * @param dependencies - Array of nodes with their dependencies
 * @param errorPrefix - Prefix for the error message (e.g., "table calculations", "metrics")
 * @throws Error if a circular dependency is detected, with the full cycle path
 */
function detectCircularDependencies(
  dependencies: DependencyNode[],
  errorPrefix?: string
): void;

Examples

Basic Circular Dependency Detection

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

// Check for circular dependencies in table calculations
const tableCalculations: DependencyNode[] = [
  { name: 'profit', dependencies: ['revenue', 'cost'] },
  { name: 'margin', dependencies: ['profit', 'revenue'] },
  { name: 'revenue', dependencies: [] },
  { name: 'cost', dependencies: [] },
];

try {
  detectCircularDependencies(tableCalculations, 'table calculations');
  console.log('No circular dependencies found');
} catch (error) {
  console.error(error.message);
}

Detecting Circular Dependencies

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

// Example with circular dependency
const circularDeps: DependencyNode[] = [
  { name: 'A', dependencies: ['B'] },
  { name: 'B', dependencies: ['C'] },
  { name: 'C', dependencies: ['A'] }, // Creates cycle: A -> B -> C -> A
];

try {
  detectCircularDependencies(circularDeps, 'metrics');
} catch (error) {
  console.error(error.message);
  // Error: "Circular dependency detected in metrics: A -> B -> C -> A"
}

Custom Metrics Validation

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

// Use with custom metrics
const customMetrics: DependencyNode[] = metricQuery.additionalMetrics.map(metric => ({
  name: metric.name,
  dependencies: extractDependencies(metric.sql), // Extract field references
}));

detectCircularDependencies(customMetrics, 'custom metrics');

// Helper function to extract dependencies from SQL
function extractDependencies(sql: string): string[] {
  const regex = /\$\{([^}]+)\}/g;
  const matches = [];
  let match;

  while ((match = regex.exec(sql)) !== null) {
    matches.push(match[1]);
  }

  return matches;
}

Table Calculation Validation

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

function validateTableCalculations(calculations: TableCalculation[]): void {
  // Build dependency graph
  const dependencies: DependencyNode[] = calculations.map(calc => ({
    name: calc.name,
    dependencies: extractFieldReferences(calc.sql),
  }));

  // Check for circular dependencies
  try {
    detectCircularDependencies(dependencies, 'table calculations');
  } catch (error) {
    throw new Error(`Invalid table calculations: ${error.message}`);
  }
}

// Extract field references from SQL expression
function extractFieldReferences(sql: string): string[] {
  const references: string[] = [];
  const regex = /\$\{([^}]+)\}/g;
  let match;

  while ((match = regex.exec(sql)) !== null) {
    references.push(match[1]);
  }

  return references;
}

// Usage
const calculations: TableCalculation[] = [
  {
    name: 'total_revenue',
    sql: '${unit_price} * ${quantity}',
  },
  {
    name: 'profit',
    sql: '${total_revenue} - ${cost}',
  },
  {
    name: 'margin',
    sql: '${profit} / ${total_revenue}',
  },
];

validateTableCalculations(calculations); // No circular dependencies

Custom Dimension Validation

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

function validateCustomDimensions(dimensions: CustomDimension[]): void {
  const dependencies: DependencyNode[] = dimensions.map(dim => ({
    name: dim.name,
    dependencies: extractDimensionDependencies(dim),
  }));

  detectCircularDependencies(dependencies, 'custom dimensions');
}

function extractDimensionDependencies(dimension: CustomDimension): string[] {
  if (dimension.type === 'sql') {
    return extractFieldReferences(dimension.sql);
  } else if (dimension.type === 'bin') {
    return [dimension.dimensionId];
  }
  return [];
}

API Request Validation

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

// Validate metric query before execution
app.post('/api/queries', async (req, res) => {
  const { metricQuery } = req.body;

  try {
    // Validate custom metrics don't have circular dependencies
    if (metricQuery.additionalMetrics?.length > 0) {
      const dependencies: DependencyNode[] = metricQuery.additionalMetrics.map(
        (metric: AdditionalMetric) => ({
          name: metric.name,
          dependencies: extractFieldReferences(metric.sql),
        })
      );

      detectCircularDependencies(dependencies, 'custom metrics');
    }

    // Execute query
    const results = await executeMetricQuery(metricQuery);
    res.json(results);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Complex Dependency Graph

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

// Complex calculation dependencies
const complexCalculations: DependencyNode[] = [
  // Base metrics
  { name: 'revenue', dependencies: [] },
  { name: 'cost', dependencies: [] },
  { name: 'quantity', dependencies: [] },

  // Derived metrics
  { name: 'profit', dependencies: ['revenue', 'cost'] },
  { name: 'unit_profit', dependencies: ['profit', 'quantity'] },
  { name: 'margin_percent', dependencies: ['profit', 'revenue'] },

  // High-level metrics
  { name: 'roi', dependencies: ['profit', 'cost'] },
  { name: 'efficiency', dependencies: ['unit_profit', 'cost'] },
];

try {
  detectCircularDependencies(complexCalculations, 'business metrics');
  console.log('All metrics are valid - no circular dependencies');
} catch (error) {
  console.error('Invalid metric structure:', error.message);
}

Pre-save Validation

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

async function saveMetricDefinition(metric: MetricDefinition) {
  // Get all existing metrics
  const existingMetrics = await getExistingMetrics();

  // Build dependency graph including new metric
  const allMetrics: DependencyNode[] = [
    ...existingMetrics.map(m => ({
      name: m.name,
      dependencies: extractFieldReferences(m.sql),
    })),
    {
      name: metric.name,
      dependencies: extractFieldReferences(metric.sql),
    },
  ];

  // Validate before saving
  try {
    detectCircularDependencies(allMetrics, 'metrics');
    await database.metrics.insert(metric);
    console.log(`Metric '${metric.name}' saved successfully`);
  } catch (error) {
    throw new Error(`Cannot save metric: ${error.message}`);
  }
}

Testing

import { detectCircularDependencies, type DependencyNode } from '@lightdash/common';

describe('Dependency graph validation', () => {
  it('should detect simple circular dependency', () => {
    const deps: DependencyNode[] = [
      { name: 'A', dependencies: ['B'] },
      { name: 'B', dependencies: ['A'] },
    ];

    expect(() => {
      detectCircularDependencies(deps);
    }).toThrow('Circular dependency detected');
  });

  it('should detect complex circular dependency', () => {
    const deps: DependencyNode[] = [
      { name: 'A', dependencies: ['B'] },
      { name: 'B', dependencies: ['C'] },
      { name: 'C', dependencies: ['D'] },
      { name: 'D', dependencies: ['B'] }, // Cycle: B -> C -> D -> B
    ];

    expect(() => {
      detectCircularDependencies(deps, 'metrics');
    }).toThrow('Circular dependency detected in metrics: B -> C -> D -> B');
  });

  it('should allow valid dependency graph', () => {
    const deps: DependencyNode[] = [
      { name: 'A', dependencies: ['B', 'C'] },
      { name: 'B', dependencies: ['D'] },
      { name: 'C', dependencies: ['D'] },
      { name: 'D', dependencies: [] },
    ];

    expect(() => {
      detectCircularDependencies(deps);
    }).not.toThrow();
  });
});

Algorithm

The function uses depth-first search (DFS) with cycle detection:

  1. Maintains a set of nodes currently being visited (on the current path)
  2. For each node, recursively visits all dependencies
  3. If a dependency is already on the current path, a cycle is detected
  4. Reports the full cycle path in the error message

Use Cases

  • Table Calculations: Validate calculation dependencies before execution
  • Custom Metrics: Ensure metric definitions don't reference each other circularly
  • Custom Dimensions: Validate dimension dependencies
  • DAG Validation: Ensure directed acyclic graph structure
  • Pre-save Validation: Check dependencies before saving definitions
  • API Validation: Validate query requests before execution

Error Messages

The function provides clear error messages including:

  • The type of object (from errorPrefix parameter)
  • The complete circular dependency path
  • All nodes involved in the cycle

Example error:

Circular dependency detected in table calculations: profit -> margin -> profit_margin -> profit

Related Utilities

  • Field References: Use SQL parsing to extract field references
  • Validation: Part of broader validation strategy for metric queries
  • Graph Algorithms: Uses DFS for efficient cycle detection