UNPKG

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
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