rawsql-ts
Version:
[beta]High-performance SQL parser and AST analyzer written in TypeScript. Provides fast parsing and advanced transformation capabilities.
457 lines • 19.9 kB
JavaScript
import { SimpleSelectQuery } from "../models/SimpleSelectQuery";
import { CTEDependencyAnalyzer } from "./CTEDependencyAnalyzer";
import { CTECollector } from "./CTECollector";
import { SqlFormatter } from "./SqlFormatter";
import { CommentEditor } from "../utils/CommentEditor";
import { SelectQueryParser } from "../parsers/SelectQueryParser";
import { CTEComposer } from "./CTEComposer";
/**
* Decomposes complex CTEs into executable standalone queries
*
* This class analyzes Common Table Expressions and generates executable standalone queries
* for each CTE, making complex CTE debugging easier. It supports:
* - Recursive CTE detection and handling
* - Dependency analysis (dependencies and dependents for each CTE)
* - Configurable SQL formatter options (MySQL, PostgreSQL, custom styles)
* - Optional comment generation showing CTE metadata and relationships
* - Comprehensive error handling for circular dependencies
*
* @example
* ```typescript
* const decomposer = new CTEQueryDecomposer({
* preset: 'postgres',
* addComments: true,
* keywordCase: 'upper'
* });
*
* const query = `
* with users_data as (select * from users),
* active_users as (select * from users_data where active = true)
* select * from active_users
* `;
*
* const decomposed = decomposer.decompose(SelectQueryParser.parse(query));
* // Returns array of DecomposedCTE objects with executable queries
* ```
*
* @public
*/
export class CTEQueryDecomposer {
/**
* Creates a new CTEQueryDecomposer instance
* @param options - Configuration options extending SqlFormatterOptions
*/
constructor(options = {}) {
this.options = options;
this.dependencyAnalyzer = new CTEDependencyAnalyzer();
this.cteCollector = new CTECollector();
this.formatter = new SqlFormatter(options);
}
/**
* Decomposes CTEs in a query into executable standalone queries
*
* This method analyzes the query structure to:
* 1. Collect all CTEs and analyze their dependencies
* 2. Detect recursive CTEs and handle them separately
* 3. Generate executable queries for each CTE including required dependencies
* 4. Add optional comments with metadata (if addComments option is enabled)
* 5. Format output according to specified formatter options
*
* @param query - The SimpleSelectQuery containing CTEs to decompose
* @returns Array of decomposed CTEs with executable queries, dependencies, and metadata
* @throws Error if circular dependencies are detected in non-recursive CTEs
*
* @example
* ```typescript
* const query = SelectQueryParser.parse(`
* with base as (select * from users),
* filtered as (select * from base where active = true)
* select * from filtered
* `);
*
* const result = decomposer.decompose(query);
* // Returns:
* // [
* // { name: 'base', query: 'select * from users', dependencies: [], ... },
* // { name: 'filtered', query: 'with base as (...) select * from base where active = true', dependencies: ['base'], ... }
* // ]
* ```
*/
decompose(query) {
const ctes = this.cteCollector.collect(query);
if (ctes.length === 0) {
return [];
}
const recursiveCTEs = this.findRecursiveCTEs(query, ctes);
const dependencyGraph = this.dependencyAnalyzer.analyzeDependencies(query);
this.validateCircularDependencies(recursiveCTEs.length > 0);
return this.processCTENodes(query, dependencyGraph.nodes, recursiveCTEs);
}
/**
* Synchronizes edited CTEs back into a unified query and re-decomposes them
*
* This method resolves inconsistencies between edited CTEs by:
* 1. Composing the edited CTEs into a unified query
* 2. Parsing the unified query to ensure consistency
* 3. Re-decomposing the synchronized query
*
* This is useful when CTEs have been edited independently and may have
* inconsistencies that need to be resolved through a unified composition.
*
* @param editedCTEs - Array of edited CTEs that may have inconsistencies
* @param rootQuery - The main query that uses the CTEs
* @returns Array of re-decomposed CTEs with resolved inconsistencies
* @throws Error if the composed query cannot be parsed or contains errors
*
* @example
* ```typescript
* // After editing CTEs independently, synchronize them
* const editedCTEs = [
* { name: 'users_data', query: 'select * from users where active = true' },
* { name: 'active_users', query: 'select * from users_data where id >= 1000' }
* ];
*
* const synchronized = decomposer.synchronize(editedCTEs, 'select count(*) from active_users');
* // Returns re-decomposed CTEs with resolved dependencies
* ```
*/
synchronize(editedCTEs, rootQuery) {
if (editedCTEs.length === 0) {
return [];
}
// Flatten nested WITH clauses by extracting sub-CTEs from edited CTEs
const flattenedCTEs = this.flattenNestedWithClauses(editedCTEs);
// Use CTEComposer to create a unified query, sharing formatter options
const composerOptions = Object.assign(Object.assign({}, this.options), {
// Remove decomposer-specific options
addComments: undefined });
const composer = new CTEComposer(composerOptions);
const unifiedQuery = composer.compose(flattenedCTEs, rootQuery);
// Parse the unified query
const parsedQuery = SelectQueryParser.parse(unifiedQuery);
// Re-decompose the synchronized query
return this.decompose(parsedQuery);
}
/**
* Flattens nested WITH clauses by extracting sub-CTEs from edited CTEs
* @param editedCTEs Array of edited CTEs that may contain nested WITH clauses
* @returns Flattened array of CTEs with sub-CTEs extracted to top level
*/
flattenNestedWithClauses(editedCTEs) {
const flattened = [];
const extractedCTEs = new Map(); // name -> query mapping for extracted CTEs
for (const editedCTE of editedCTEs) {
try {
// Check if this CTE's query contains a WITH clause
const withPattern = /^\s*with\s+/i;
if (withPattern.test(editedCTE.query)) {
// Parse the query to extract nested CTEs
const parsed = SelectQueryParser.parse(editedCTE.query);
if (parsed.withClause && parsed.withClause.tables) {
// Extract each nested CTE
for (const nestedCTE of parsed.withClause.tables) {
const cteName = this.getCTEName(nestedCTE);
if (!extractedCTEs.has(cteName)) {
// Format the nested CTE query
const cteFormatter = new SqlFormatter({
identifierEscape: { start: "", end: "" }
});
const nestedQuery = cteFormatter.format(nestedCTE.query).formattedSql;
extractedCTEs.set(cteName, nestedQuery);
}
}
// Create the main query without the WITH clause
const mainQueryWithoutWith = new SimpleSelectQuery({
selectClause: parsed.selectClause,
fromClause: parsed.fromClause,
whereClause: parsed.whereClause,
groupByClause: parsed.groupByClause,
havingClause: parsed.havingClause,
orderByClause: parsed.orderByClause,
windowClause: parsed.windowClause,
limitClause: parsed.limitClause,
offsetClause: parsed.offsetClause,
fetchClause: parsed.fetchClause,
forClause: parsed.forClause,
withClause: undefined // Remove WITH clause
});
const mainFormatter = new SqlFormatter({
identifierEscape: { start: "", end: "" }
});
const mainQuery = mainFormatter.format(mainQueryWithoutWith).formattedSql;
flattened.push({
name: editedCTE.name,
query: mainQuery
});
}
else {
// WITH clause exists but no tables found, preserve as-is
flattened.push(editedCTE);
}
}
else {
// No WITH clause, preserve as-is
flattened.push(editedCTE);
}
}
catch (error) {
// If parsing fails, preserve the original CTE
flattened.push(editedCTE);
}
}
// Add extracted CTEs at the beginning (they need to be defined first)
const result = [];
for (const [name, query] of extractedCTEs) {
result.push({ name, query });
}
result.push(...flattened);
return result;
}
/**
* Validates circular dependencies for non-recursive CTEs
* @param hasRecursiveCTEs Whether the query contains recursive CTEs
* @throws Error if circular dependencies exist in non-recursive CTEs
*/
validateCircularDependencies(hasRecursiveCTEs) {
if (this.dependencyAnalyzer.hasCircularDependency() && !hasRecursiveCTEs) {
throw new Error(CTEQueryDecomposer.ERROR_MESSAGES.CIRCULAR_REFERENCE);
}
}
/**
* Processes CTE nodes and generates decomposed CTEs
* @param query Original query
* @param nodes CTE dependency nodes
* @param recursiveCTEs List of recursive CTE names
* @returns Array of decomposed CTEs
*/
processCTENodes(query, nodes, recursiveCTEs) {
const result = [];
for (const node of nodes) {
// Skip ROOT nodes (main query) as they are not CTEs
if (node.type === 'ROOT') {
continue;
}
const isRecursive = recursiveCTEs.includes(node.name);
if (isRecursive) {
result.push(this.createRecursiveCTE(node, query));
}
else {
result.push(this.createStandardCTE(node, nodes));
}
}
return result;
}
/**
* Creates a decomposed CTE for recursive CTEs
*/
createRecursiveCTE(node, query) {
const formattedQuery = this.formatter.format(query).formattedSql;
// Filter out MAIN_QUERY from dependents for DecomposedCTE result
const cteDependents = node.dependents.filter(dep => dep !== 'MAIN_QUERY');
const finalQuery = this.addCommentsToQuery(formattedQuery, node.name, node.dependencies, node.dependents, true);
return {
name: node.name,
query: finalQuery,
dependencies: [...node.dependencies],
dependents: cteDependents,
isRecursive: true
};
}
/**
* Creates a decomposed CTE for standard (non-recursive) CTEs
*/
createStandardCTE(node, allNodes) {
const query = this.buildExecutableQuery(node, allNodes);
const finalQuery = this.addCommentsToQuery(query, node.name, node.dependencies, node.dependents, false);
// Filter out MAIN_QUERY from dependents for DecomposedCTE result
const cteDependents = node.dependents.filter(dep => dep !== 'MAIN_QUERY');
return {
name: node.name,
query: finalQuery,
dependencies: [...node.dependencies],
dependents: cteDependents,
isRecursive: false
};
}
/**
* Builds an executable query for a CTE by including its dependencies
*/
buildExecutableQuery(targetNode, allNodes) {
// ROOT nodes don't have a cte property
if (targetNode.type === 'ROOT' || !targetNode.cte) {
throw new Error(`Cannot build executable query for ROOT node: ${targetNode.name}`);
}
const requiredCTEs = this.collectRequiredCTEs(targetNode, allNodes);
if (requiredCTEs.length === 0) {
// No dependencies, just return the CTE's query
return this.formatter.format(targetNode.cte.query).formattedSql;
}
// Build WITH clause with required CTEs
const withClause = this.buildWithClause(requiredCTEs);
const mainQuery = this.formatter.format(targetNode.cte.query).formattedSql;
return `${withClause} ${mainQuery}`;
}
/**
* Collects all required CTEs for a target CTE in dependency order
*/
collectRequiredCTEs(targetNode, allNodes) {
const visited = new Set();
const result = [];
const nodeMap = new Map();
// Build node lookup map
for (const node of allNodes) {
nodeMap.set(node.name, node);
}
const collectDependencies = (nodeName) => {
if (visited.has(nodeName))
return;
visited.add(nodeName);
const node = nodeMap.get(nodeName);
if (!node)
return;
// First collect all dependencies
for (const depName of node.dependencies) {
collectDependencies(depName);
}
// Then add this node (ensuring dependencies come first)
// Skip ROOT nodes and the target node itself
if (nodeName !== targetNode.name && node.type !== 'ROOT') {
result.push(node);
}
};
// Collect dependencies for the target node
for (const depName of targetNode.dependencies) {
collectDependencies(depName);
}
return result;
}
/**
* Builds WITH clause from required CTEs
*/
buildWithClause(requiredCTEs) {
if (requiredCTEs.length === 0)
return "";
const cteDefinitions = requiredCTEs.map(node => {
// Skip ROOT nodes as they don't have CTE definitions
if (node.type === 'ROOT' || !node.cte) {
throw new Error(`Cannot include ROOT node in WITH clause: ${node.name}`);
}
const cteName = node.name;
const cteQuery = this.formatter.format(node.cte.query).formattedSql;
return `${cteName} as (${cteQuery})`;
});
return `with ${cteDefinitions.join(", ")}`;
}
/**
* Finds recursive CTEs in the query
* @param query The query to analyze
* @param ctes All CTEs in the query
* @returns Array of recursive CTE names
*/
findRecursiveCTEs(query, ctes) {
if (!query.withClause || !this.isRecursiveWithClause(query)) {
return [];
}
// For now, assume all CTEs in a RECURSIVE WITH are recursive
// This is a simplification - in reality, only some CTEs might be recursive
return ctes.map(cte => this.getCTEName(cte));
}
/**
* Checks if a WITH clause is recursive
* @param query The query to check
* @returns true if the query contains WITH RECURSIVE
*/
isRecursiveWithClause(query) {
const queryText = this.formatter.format(query).formattedSql.toLowerCase();
return queryText.includes('with recursive');
}
/**
* Adds comments to the query if addComments option is enabled
* @param query The query string to add comments to
* @param cteName The name of the CTE
* @param dependencies Array of dependency names
* @param dependents Array of dependent names
* @param isRecursive Whether this is a recursive CTE
* @returns Query with comments added if enabled, otherwise original query
*/
addCommentsToQuery(query, cteName, dependencies, dependents, isRecursive) {
if (this.options.addComments !== true) {
return query;
}
try {
// Parse the query to add comments to its AST
const parsedQuery = SelectQueryParser.parse(query);
// Generate comment lines
const comments = this.generateComments(cteName, dependencies, dependents, isRecursive);
// Add comments to the root query (this should place them at the beginning)
comments.forEach(comment => {
CommentEditor.addComment(parsedQuery, comment);
});
// Format with comment export enabled
const formatterWithComments = new SqlFormatter(Object.assign(Object.assign({}, this.options), { exportComment: true }));
return formatterWithComments.format(parsedQuery).formattedSql;
}
catch (error) {
// If parsing fails, return original query with simple text comments
console.warn(`${CTEQueryDecomposer.ERROR_MESSAGES.PARSING_FAILED}: ${error}`);
return this.addTextCommentsToQuery(query, cteName, dependencies, dependents, isRecursive);
}
}
/**
* Generates comment lines for a CTE
* @param cteName The name of the CTE
* @param dependencies Array of dependency names
* @param dependents Array of dependent names
* @param isRecursive Whether this is a recursive CTE
* @returns Array of comment strings
*/
generateComments(cteName, dependencies, dependents, isRecursive) {
const { AUTO_GENERATED, ORIGINAL_CTE, DEPENDENCIES, DEPENDENTS, RECURSIVE_TYPE, NONE } = CTEQueryDecomposer.COMMENT_TEXTS;
const comments = [];
comments.push(AUTO_GENERATED);
comments.push(`${ORIGINAL_CTE} ${cteName}`);
if (isRecursive) {
comments.push(RECURSIVE_TYPE);
}
const depsText = dependencies.length > 0 ? dependencies.join(", ") : NONE;
comments.push(`${DEPENDENCIES} ${depsText}`);
// Filter out MAIN_QUERY from dependents for display purposes
const cteDependents = dependents.filter(dep => dep !== 'MAIN_QUERY');
const dependentsText = cteDependents.length > 0 ? cteDependents.join(", ") : NONE;
comments.push(`${DEPENDENTS} ${dependentsText}`);
return comments;
}
/**
* Adds text comments as SQL comments when AST parsing fails
* @param query Original query
* @param cteName The name of the CTE
* @param dependencies Array of dependency names
* @param dependents Array of dependent names
* @param isRecursive Whether this is a recursive CTE
* @returns Query with text comments prepended
*/
addTextCommentsToQuery(query, cteName, dependencies, dependents, isRecursive) {
const comments = this.generateComments(cteName, dependencies, dependents, isRecursive);
const commentLines = comments.map(comment => `-- ${comment}`).join('\n');
return `${commentLines}\n${query}`;
}
/**
* Extracts the name from a CommonTable
*/
getCTEName(cte) {
return cte.aliasExpression.table.name;
}
}
CTEQueryDecomposer.ERROR_MESSAGES = {
CIRCULAR_REFERENCE: "Circular reference detected in non-recursive CTEs",
PARSING_FAILED: "Failed to parse query for comment injection"
};
CTEQueryDecomposer.COMMENT_TEXTS = {
AUTO_GENERATED: "Auto-generated by CTE decomposer",
ORIGINAL_CTE: "Original CTE:",
DEPENDENCIES: "Dependencies:",
DEPENDENTS: "Dependents:",
RECURSIVE_TYPE: "Type: Recursive CTE",
NONE: "none"
};
//# sourceMappingURL=CTEQueryDecomposer.js.map