UNPKG

rawsql-ts

Version:

[beta]High-performance SQL parser and AST analyzer written in TypeScript. Provides fast parsing and advanced transformation capabilities.

648 lines 28.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SimpleSelectQuery = void 0; const SqlComponent_1 = require("./SqlComponent"); const Clause_1 = require("./Clause"); const ValueComponent_1 = require("./ValueComponent"); const ValueParser_1 = require("../parsers/ValueParser"); const CTENormalizer_1 = require("../transformers/CTENormalizer"); const SelectableColumnCollector_1 = require("../transformers/SelectableColumnCollector"); const SourceParser_1 = require("../parsers/SourceParser"); const CTEError_1 = require("./CTEError"); const SelectQueryParser_1 = require("../parsers/SelectQueryParser"); const Formatter_1 = require("../transformers/Formatter"); const UpstreamSelectQueryFinder_1 = require("../transformers/UpstreamSelectQueryFinder"); const QueryBuilder_1 = require("../transformers/QueryBuilder"); const ParameterHelper_1 = require("../utils/ParameterHelper"); /** * Represents a single SELECT statement with full clause support (WITH, JOIN, GROUP BY, etc.). * Provides the fluent CTE management API used throughout packages/core/tests/models/SelectQuery.cte-management.test.ts. * * @example * ```typescript * const query = SelectQueryParser.parse('SELECT id, email FROM users').toSimpleQuery(); * const active = SelectQueryParser.parse('SELECT id FROM users WHERE active = true'); * * query * .addCTE('active_users', active) * .toUnionAll(SelectQueryParser.parse('SELECT id, email FROM legacy_users')); * ``` */ class SimpleSelectQuery extends SqlComponent_1.SqlComponent { constructor(params) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; super(); this.__selectQueryType = 'SelectQuery'; // Discriminator for type safety this.headerComments = null; // Comments that appear before WITH clause // Performance optimization: O(1) CTE name lookups this.cteNameCache = new Set(); this.withClause = (_a = params.withClause) !== null && _a !== void 0 ? _a : null; this.selectClause = params.selectClause; this.fromClause = (_b = params.fromClause) !== null && _b !== void 0 ? _b : null; this.whereClause = (_c = params.whereClause) !== null && _c !== void 0 ? _c : null; this.groupByClause = (_d = params.groupByClause) !== null && _d !== void 0 ? _d : null; this.havingClause = (_e = params.havingClause) !== null && _e !== void 0 ? _e : null; this.orderByClause = (_f = params.orderByClause) !== null && _f !== void 0 ? _f : null; this.windowClause = (_g = params.windowClause) !== null && _g !== void 0 ? _g : null; this.limitClause = (_h = params.limitClause) !== null && _h !== void 0 ? _h : null; this.offsetClause = (_j = params.offsetClause) !== null && _j !== void 0 ? _j : null; this.fetchClause = (_k = params.fetchClause) !== null && _k !== void 0 ? _k : null; this.forClause = (_l = params.forClause) !== null && _l !== void 0 ? _l : null; // Initialize CTE name cache from existing withClause this.initializeCTECache(); } /** * Initializes the CTE name cache from existing withClause. * Called during construction and when withClause is modified externally. * @private */ initializeCTECache() { var _a; this.cteNameCache.clear(); if ((_a = this.withClause) === null || _a === void 0 ? void 0 : _a.tables) { for (const table of this.withClause.tables) { this.cteNameCache.add(table.aliasExpression.table.name); } } } /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using UNION as the operator. * * @param rightQuery The right side of the UNION * @returns A new BinarySelectQuery representing "this UNION rightQuery" */ toUnion(rightQuery) { return this.toBinaryQuery('union', rightQuery); } /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using UNION ALL as the operator. * * @param rightQuery The right side of the UNION ALL * @returns A new BinarySelectQuery representing "this UNION ALL rightQuery" */ toUnionAll(rightQuery) { return this.toBinaryQuery('union all', rightQuery); } /** * Converts this query into an INSERT statement definition. * @remarks * Calling this method may reorder the current SELECT clause to match the requested column order. */ toInsertQuery(options) { return QueryBuilder_1.QueryBuilder.buildInsertQuery(this, options); } /** * Converts this query into an UPDATE statement definition. * @remarks * The conversion may reorder the SELECT list so that primary keys and updated columns align with the target table. */ toUpdateQuery(options) { return QueryBuilder_1.QueryBuilder.buildUpdateQuery(this, options); } /** * Converts this query into a DELETE statement definition. * @remarks * The SELECT clause may be reordered to ensure primary keys and comparison columns appear first. */ toDeleteQuery(options) { return QueryBuilder_1.QueryBuilder.buildDeleteQuery(this, options); } /** * Converts this query into a MERGE statement definition. * @remarks * This method may reorder the SELECT clause to align with the specified MERGE column lists. */ toMergeQuery(options) { return QueryBuilder_1.QueryBuilder.buildMergeQuery(this, options); } /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using INTERSECT as the operator. * * @param rightQuery The right side of the INTERSECT * @returns A new BinarySelectQuery representing "this INTERSECT rightQuery" */ toIntersect(rightQuery) { return this.toBinaryQuery('intersect', rightQuery); } /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using INTERSECT ALL as the operator. * * @param rightQuery The right side of the INTERSECT ALL * @returns A new BinarySelectQuery representing "this INTERSECT ALL rightQuery" */ toIntersectAll(rightQuery) { return this.toBinaryQuery('intersect all', rightQuery); } /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using EXCEPT as the operator. * * @param rightQuery The right side of the EXCEPT * @returns A new BinarySelectQuery representing "this EXCEPT rightQuery" */ toExcept(rightQuery) { return this.toBinaryQuery('except', rightQuery); } /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using EXCEPT ALL as the operator. * * @param rightQuery The right side of the EXCEPT ALL * @returns A new BinarySelectQuery representing "this EXCEPT ALL rightQuery" */ toExceptAll(rightQuery) { return this.toBinaryQuery('except all', rightQuery); } /** * Creates a new BinarySelectQuery with this query as the left side and the provided query as the right side, * using the specified operator. * * @param operator SQL operator to use (e.g. 'union', 'union all', 'intersect', 'except') * @param rightQuery The right side of the binary operation * @returns A new BinarySelectQuery representing "this [operator] rightQuery" */ toBinaryQuery(operator, rightQuery) { return QueryBuilder_1.QueryBuilder.buildBinaryQuery([this, rightQuery], operator); } /** * Appends a new condition to the query's WHERE clause using AND logic. * The condition is provided as a raw SQL string which is parsed internally. * * @param rawCondition Raw SQL string representing the condition (e.g. "status = 'active'") */ appendWhereRaw(rawCondition) { const parsedCondition = ValueParser_1.ValueParser.parse(rawCondition); this.appendWhere(parsedCondition); } /** * Appends a new condition to the query's WHERE clause using AND logic. * The condition is provided as a ValueComponent object. * * @param condition ValueComponent representing the condition */ appendWhere(condition) { if (!this.whereClause) { this.whereClause = new Clause_1.WhereClause(condition); } else { this.whereClause.condition = new ValueComponent_1.BinaryExpression(this.whereClause.condition, 'and', condition); } } /** * Appends a new condition to the query's HAVING clause using AND logic. * The condition is provided as a raw SQL string which is parsed internally. * * @param rawCondition Raw SQL string representing the condition (e.g. "count(*) > 5") */ appendHavingRaw(rawCondition) { const parsedCondition = ValueParser_1.ValueParser.parse(rawCondition); this.appendHaving(parsedCondition); } /** * Appends a new condition to the query's HAVING clause using AND logic. * The condition is provided as a ValueComponent object. * * @param condition ValueComponent representing the condition */ appendHaving(condition) { if (!this.havingClause) { this.havingClause = new Clause_1.HavingClause(condition); } else { this.havingClause.condition = new ValueComponent_1.BinaryExpression(this.havingClause.condition, 'and', condition); } } /** * Appends an INNER JOIN clause to the query. * @param joinSourceRawText The table source text to join (e.g., "my_table", "schema.my_table") * @param alias The alias for the joined table * @param columns The columns to use for the join condition (e.g. ["user_id"] or "user_id") */ innerJoinRaw(joinSourceRawText, alias, columns, resolver = null) { this.joinSourceRaw('inner join', joinSourceRawText, alias, columns, resolver); } /** * Appends a LEFT JOIN clause to the query. * @param joinSourceRawText The table source text to join * @param alias The alias for the joined table * @param columns The columns to use for the join condition */ leftJoinRaw(joinSourceRawText, alias, columns, resolver = null) { this.joinSourceRaw('left join', joinSourceRawText, alias, columns, resolver); } /** * Appends a RIGHT JOIN clause to the query. * @param joinSourceRawText The table source text to join * @param alias The alias for the joined table * @param columns The columns to use for the join condition */ rightJoinRaw(joinSourceRawText, alias, columns, resolver = null) { this.joinSourceRaw('right join', joinSourceRawText, alias, columns, resolver); } /** * Appends an INNER JOIN clause to the query using a SourceExpression. * @param sourceExpr The source expression to join * @param columns The columns to use for the join condition */ innerJoin(sourceExpr, columns, resolver = null) { this.joinSource('inner join', sourceExpr, columns, resolver); } /** * Appends a LEFT JOIN clause to the query using a SourceExpression. * @param sourceExpr The source expression to join * @param columns The columns to use for the join condition */ leftJoin(sourceExpr, columns, resolver = null) { this.joinSource('left join', sourceExpr, columns, resolver); } /** * Appends a RIGHT JOIN clause to the query using a SourceExpression. * @param sourceExpr The source expression to join * @param columns The columns to use for the join condition */ rightJoin(sourceExpr, columns, resolver = null) { this.joinSource('right join', sourceExpr, columns, resolver); } /** * Internal helper to append a JOIN clause. * Parses the table source, finds the corresponding columns in the existing query context, * and builds the JOIN condition. * @param joinType Type of join (e.g., 'inner join', 'left join') * @param joinSourceRawText Raw text for the table/source to join (e.g., "my_table", "schema.another_table") * @param alias Alias for the table/source being joined * @param columns Array or string of column names to join on */ joinSourceRaw(joinType, joinSourceRawText, alias, columns, resolver = null) { const tableSource = SourceParser_1.SourceParser.parse(joinSourceRawText); const sourceExpr = new Clause_1.SourceExpression(tableSource, new Clause_1.SourceAliasExpression(alias, null)); this.joinSource(joinType, sourceExpr, columns, resolver); } /** * Internal helper to append a JOIN clause using a SourceExpression. * @param joinType Type of join (e.g., 'inner join', 'left join') * @param sourceExpr The source expression to join * @param columns Array or string of column names to join on */ joinSource(joinType, sourceExpr, columns, resolver = null) { if (!this.fromClause) { throw new Error('A FROM clause is required to add a JOIN condition.'); } // Always treat columns as array const columnsArr = Array.isArray(columns) ? columns : [columns]; const collector = new SelectableColumnCollector_1.SelectableColumnCollector(resolver); const valueSets = collector.collect(this); let joinCondition = null; let count = 0; const sourceAlias = sourceExpr.getAliasName(); if (!sourceAlias) { throw new Error('An alias is required for the source expression to add a JOIN condition.'); } for (const valueSet of valueSets) { if (columnsArr.some(col => col == valueSet.name)) { const expr = new ValueComponent_1.BinaryExpression(valueSet.value, '=', new ValueComponent_1.ColumnReference([sourceAlias], valueSet.name)); if (joinCondition) { joinCondition = new ValueComponent_1.BinaryExpression(joinCondition, 'and', expr); } else { joinCondition = expr; } count++; } } if (!joinCondition || count !== columnsArr.length) { throw new Error(`Invalid JOIN condition. The specified columns were not found: ${columnsArr.join(', ')}`); } const joinOnClause = new Clause_1.JoinOnClause(joinCondition); const joinClause = new Clause_1.JoinClause(joinType, sourceExpr, joinOnClause, false); if (this.fromClause) { if (this.fromClause.joins) { this.fromClause.joins.push(joinClause); } else { this.fromClause.joins = [joinClause]; } } CTENormalizer_1.CTENormalizer.normalize(this); } // Returns a SourceExpression wrapping this query as a subquery source. // Alias is required for correct SQL generation and join logic. toSource(alias) { if (!alias || alias.trim() === "") { throw new Error("Alias is required for toSource(). Please specify a non-empty alias name."); } return new Clause_1.SourceExpression(new Clause_1.SubQuerySource(this), new Clause_1.SourceAliasExpression(alias, null)); } appendWith(commonTable) { // Always treat as array for simplicity const tables = Array.isArray(commonTable) ? commonTable : [commonTable]; if (!this.withClause) { this.withClause = new Clause_1.WithClause(false, tables); } else { this.withClause.tables.push(...tables); } CTENormalizer_1.CTENormalizer.normalize(this); } /** * Appends a CommonTable (CTE) to the WITH clause from raw SQL text and alias. * If alias is provided, it will be used as the CTE name. * * @param rawText Raw SQL string representing the CTE body (e.g. '(SELECT ...)') * @param alias Optional alias for the CTE (e.g. 'cte_name') */ appendWithRaw(rawText, alias) { const query = SelectQueryParser_1.SelectQueryParser.parse(rawText); const commonTable = new Clause_1.CommonTable(query, alias, null); this.appendWith(commonTable); } /** * Overrides a select item using a template literal function. * The callback receives the SQL string of the original expression and must return a new SQL string. * The result is parsed and set as the new select item value. * * Example usage: * query.overrideSelectItemRaw("journal_date", expr => `greatest(${expr}, DATE '2025-01-01')`) * * @param columnName The name of the column to override * @param fn Callback that receives the SQL string of the original expression and returns a new SQL string */ overrideSelectItemExpr(columnName, fn) { const items = this.selectClause.items.filter(item => { var _a; return ((_a = item.identifier) === null || _a === void 0 ? void 0 : _a.name) === columnName; }); if (items.length === 0) { throw new Error(`Column ${columnName} not found in the query`); } if (items.length > 1) { throw new Error(`Duplicate column name ${columnName} found in the query`); } const item = items[0]; const formatter = new Formatter_1.Formatter(); const exprSql = formatter.visit(item.value); const newValue = fn(exprSql); item.value = ValueParser_1.ValueParser.parse(newValue); } /** * Appends a WHERE clause using the expression for the specified column. * If `options.upstream` is true, applies to all upstream queries containing the column. * If false or omitted, applies only to the current query. * * @param columnName The name of the column to target. * @param exprBuilder Function that receives the column expression as a string and returns the WHERE condition string. * @param options Optional settings. If `upstream` is true, applies to upstream queries. */ appendWhereExpr(columnName, exprBuilder, options) { // If upstream option is true, find all upstream queries containing the column if (options && options.upstream) { // Use UpstreamSelectQueryFinder to find all relevant queries // (Assume UpstreamSelectQueryFinder is imported) const finder = new UpstreamSelectQueryFinder_1.UpstreamSelectQueryFinder(); const queries = finder.find(this, [columnName]); const collector = new SelectableColumnCollector_1.SelectableColumnCollector(); const formatter = new Formatter_1.Formatter(); for (const q of queries) { const exprs = collector.collect(q).filter(item => item.name === columnName).map(item => item.value); if (exprs.length !== 1) { throw new Error(`Expected exactly one expression for column '${columnName}'`); } const exprStr = formatter.format(exprs[0]); q.appendWhereRaw(exprBuilder(exprStr)); } } else { // Only apply to the current query const collector = new SelectableColumnCollector_1.SelectableColumnCollector(); const formatter = new Formatter_1.Formatter(); const exprs = collector.collect(this).filter(item => item.name === columnName).map(item => item.value); if (exprs.length !== 1) { throw new Error(`Expected exactly one expression for column '${columnName}'`); } const exprStr = formatter.format(exprs[0]); this.appendWhereRaw(exprBuilder(exprStr)); } } /** * Sets the value of a parameter by name in this query. * @param name Parameter name * @param value Value to set */ setParameter(name, value) { ParameterHelper_1.ParameterHelper.set(this, name, value); return this; } /** * Returns this SimpleSelectQuery instance (identity function). * @returns This SimpleSelectQuery instance */ toSimpleQuery() { return this; } /** * Adds a CTE (Common Table Expression) to the query. * * @param name CTE name/alias (must be non-empty) * @param query SelectQuery to use as CTE * @param options Optional configuration * @param options.materialized PostgreSQL-specific: true = MATERIALIZED, false = NOT MATERIALIZED, null/undefined = no hint * * @throws {InvalidCTENameError} When name is empty or whitespace-only * @throws {DuplicateCTEError} When CTE with same name already exists * * @example * ```typescript * // Basic CTE * query.addCTE('active_users', * SelectQueryParser.parse('SELECT * FROM users WHERE active = true') * ); * * // PostgreSQL MATERIALIZED CTE (forces materialization) * query.addCTE('expensive_calc', expensiveQuery, { materialized: true }); * * // PostgreSQL NOT MATERIALIZED CTE (prevents materialization) * query.addCTE('simple_view', simpleQuery, { materialized: false }); * ``` * * @remarks * - MATERIALIZED/NOT MATERIALIZED is PostgreSQL-specific syntax * - Other databases will ignore the materialized hint * - CTE names must be unique within the query * - Method supports fluent chaining */ addCTE(name, query, options) { var _a; // Validate CTE name if (!name || name.trim() === '') { throw new CTEError_1.InvalidCTENameError(name, 'name cannot be empty or whitespace-only'); } // Check for duplicate CTE name if (this.hasCTE(name)) { throw new CTEError_1.DuplicateCTEError(name); } const materialized = (_a = options === null || options === void 0 ? void 0 : options.materialized) !== null && _a !== void 0 ? _a : null; const commonTable = new Clause_1.CommonTable(query, name, materialized); this.appendWith(commonTable); // Update cache for O(1) future lookups this.cteNameCache.add(name); return this; } /** * Removes a CTE by name from the query. * * @param name CTE name to remove * * @throws {CTENotFoundError} When CTE with specified name doesn't exist * * @example * ```typescript * query.addCTE('temp_data', tempQuery); * query.removeCTE('temp_data'); // Removes the CTE * * // Throws CTENotFoundError * query.removeCTE('non_existent'); * ``` * * @remarks * - Throws error if CTE doesn't exist (strict mode for safety) * - Use hasCTE() to check existence before removal if needed * - Method supports fluent chaining */ removeCTE(name) { if (!this.hasCTE(name)) { throw new CTEError_1.CTENotFoundError(name); } if (this.withClause) { this.withClause.tables = this.withClause.tables.filter(table => table.aliasExpression.table.name !== name); if (this.withClause.tables.length === 0) { this.withClause = null; } } // Update cache for O(1) future lookups this.cteNameCache.delete(name); return this; } /** * Checks if a CTE with the given name exists in the query. * Optimized with O(1) lookup using internal cache. * * @param name CTE name to check * @returns true if CTE exists, false otherwise * * @example * ```typescript * query.addCTE('user_stats', statsQuery); * * if (query.hasCTE('user_stats')) { * console.log('CTE exists'); * } * * query.removeCTE('user_stats'); * console.log(query.hasCTE('user_stats')); // false * ``` * * @remarks * - Performs case-sensitive name matching * - Returns false for queries without any CTEs * - Useful for conditional CTE operations * - O(1) performance using internal cache */ hasCTE(name) { return this.cteNameCache.has(name); } /** * Returns an array of all CTE names in the query. * * @returns Array of CTE names in the order they were defined * * @example * ```typescript * const query = SelectQueryParser.parse('SELECT * FROM data').toSimpleQuery(); * * // Empty query * console.log(query.getCTENames()); // [] * * // Add CTEs * query.addCTE('users', userQuery); * query.addCTE('orders', orderQuery); * * console.log(query.getCTENames()); // ['users', 'orders'] * * // Use for validation * const expectedCTEs = ['users', 'orders', 'products']; * const actualCTEs = query.getCTENames(); * const missingCTEs = expectedCTEs.filter(name => !actualCTEs.includes(name)); * ``` * * @remarks * - Returns empty array for queries without CTEs * - Names are returned in definition order * - Useful for debugging and validation * - Names reflect actual CTE aliases, not table references * - Performance: O(n) but avoids redundant array mapping */ getCTENames() { var _a, _b; return (_b = (_a = this.withClause) === null || _a === void 0 ? void 0 : _a.tables.map(table => table.aliasExpression.table.name)) !== null && _b !== void 0 ? _b : []; } /** * Replaces an existing CTE or adds a new one with the given name. * * @param name CTE name to replace/add (must be non-empty) * @param query SelectQuery to use as CTE * @param options Optional configuration * @param options.materialized PostgreSQL-specific: true = MATERIALIZED, false = NOT MATERIALIZED, null/undefined = no hint * * @throws {InvalidCTENameError} When name is empty or whitespace-only * * @example * ```typescript * const query = SelectQueryParser.parse('SELECT * FROM final_data').toSimpleQuery(); * const oldQuery = SelectQueryParser.parse('SELECT id FROM old_table'); * const newQuery = SelectQueryParser.parse('SELECT id, status FROM new_table WHERE active = true'); * * // Add initial CTE * query.addCTE('data_source', oldQuery); * * // Replace with improved version * query.replaceCTE('data_source', newQuery, { materialized: true }); * * // Safe replacement - adds if doesn't exist * query.replaceCTE('new_cte', newQuery); // Won't throw error * * // Chaining replacements * query * .replaceCTE('cte1', query1, { materialized: false }) * .replaceCTE('cte2', query2, { materialized: true }); * ``` * * @remarks * - Unlike addCTE(), this method won't throw error if CTE already exists * - Unlike removeCTE(), this method won't throw error if CTE doesn't exist * - Useful for upsert-style CTE operations * - MATERIALIZED/NOT MATERIALIZED is PostgreSQL-specific * - Method supports fluent chaining * - Maintains CTE order when replacing existing CTEs */ replaceCTE(name, query, options) { var _a; // Validate CTE name if (!name || name.trim() === '') { throw new CTEError_1.InvalidCTENameError(name, 'name cannot be empty or whitespace-only'); } // Remove existing CTE if it exists (don't throw error if not found) if (this.hasCTE(name)) { this.removeCTE(name); } // Add new CTE (but skip duplicate check since we just removed it) const materialized = (_a = options === null || options === void 0 ? void 0 : options.materialized) !== null && _a !== void 0 ? _a : null; const commonTable = new Clause_1.CommonTable(query, name, materialized); this.appendWith(commonTable); // Update cache for O(1) future lookups this.cteNameCache.add(name); return this; } } exports.SimpleSelectQuery = SimpleSelectQuery; SimpleSelectQuery.kind = Symbol("SelectQuery"); //# sourceMappingURL=SimpleSelectQuery.js.map