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