rawsql-ts
Version:
High-performance SQL parser and AST analyzer written in TypeScript. Provides fast parsing and advanced transformation capabilities.
275 lines • 13.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.DynamicQueryBuilder = void 0;
const SelectQueryParser_1 = require("../parsers/SelectQueryParser");
const SqlSortInjector_1 = require("./SqlSortInjector");
const SqlPaginationInjector_1 = require("./SqlPaginationInjector");
const QueryBuilder_1 = require("./QueryBuilder");
const SqlParameterBinder_1 = require("./SqlParameterBinder");
const ParameterDetector_1 = require("../utils/ParameterDetector");
const ValueComponent_1 = require("../models/ValueComponent");
const OptimizeUnusedLeftJoins_1 = require("./OptimizeUnusedLeftJoins");
const PruneOptionalConditionBranches_1 = require("./PruneOptionalConditionBranches");
/**
* DynamicQueryBuilder combines SQL parsing with dynamic condition injection (filters, sorts, paging).
*
* Key behaviours verified in packages/core/tests/transformers/DynamicQueryBuilder.test.ts:
* - Preserves the input SQL when no options are supplied.
* - Applies filter, sort, and pagination in a deterministic order.
* - Fails fast for removed SQL-result JSON shaping.
*/
class DynamicQueryBuilder {
/**
* Creates a new DynamicQueryBuilder instance.
* Accepts either the legacy table resolver or an options object that can provide schema metadata.
*
* @param resolverOrOptions Optional resolver or configuration object
*/
constructor(resolverOrOptions) {
if (typeof resolverOrOptions === "function") {
this.tableColumnResolver = resolverOrOptions;
}
else if (resolverOrOptions) {
this.tableColumnResolver = resolverOrOptions.tableColumnResolver;
this.defaultSchemaInfo = resolverOrOptions.schemaInfo;
}
}
/**
* Builds a SelectQuery from SQL content with dynamic conditions.
* This is a pure function that does not perform any I/O operations.
* @param sqlContent Raw SQL string to parse and modify
* @param options Dynamic conditions to apply (filter, sort, paging)
* @returns Modified SelectQuery with all dynamic conditions applied
* @example
* ```typescript
* const builder = new DynamicQueryBuilder();
* const query = builder.buildQuery(
* 'SELECT id, name FROM users WHERE active = true',
* {
* filter: { status: 'premium' },
* sort: { created_at: { desc: true } },
* paging: { page: 2, pageSize: 10 }
* }
* );
* ```
*/
buildQuery(sqlContent, options = {}) {
var _a;
const removedOptions = options;
if ('serialize' in removedOptions || 'jsonb' in removedOptions) {
throw new Error("DynamicQueryBuilder SQL-result JSON shaping has been removed. Keep SQL results as rows and use generated AOT mappers so the executed SQL remains debuggable.");
}
// Parse the base SQL
let parsedQuery;
try {
parsedQuery = SelectQueryParser_1.SelectQueryParser.parse(sqlContent);
}
catch (error) {
throw new Error(`Failed to parse SQL: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Apply dynamic modifications in the correct order
let modifiedQuery = parsedQuery;
// 1. Bind existing named parameters, but fail fast for legacy runtime filter assembly.
if (options.filter && Object.keys(options.filter).length > 0) {
const { hardcodedParams, dynamicFilters } = ParameterDetector_1.ParameterDetector.separateFilters(modifiedQuery, options.filter);
// Bind hardcoded parameters if any exist
if (Object.keys(hardcodedParams).length > 0) {
const parameterBinder = new SqlParameterBinder_1.SqlParameterBinder({ requireAllParameters: false });
modifiedQuery = parameterBinder.bind(modifiedQuery, hardcodedParams);
}
const hasLegacyDynamicFilters = Object.keys(dynamicFilters).length > 0;
if (hasLegacyDynamicFilters) {
throw new Error("DynamicQueryBuilder no longer injects runtime filter predicates. Use `ashiba query optional add` to author optional filters, `ashiba query optional refresh` to refresh them, and `optionalConditionParameters` at runtime for pruning only.");
}
}
// 2. Apply sorting second (after filtering to sort smaller dataset)
if (options.sort && Object.keys(options.sort).length > 0) {
const sortInjector = new SqlSortInjector_1.SqlSortInjector(this.tableColumnResolver);
// Ensure we have a SimpleSelectQuery for the injector
const simpleQuery = QueryBuilder_1.QueryBuilder.buildSimpleQuery(modifiedQuery);
modifiedQuery = sortInjector.inject(simpleQuery, options.sort);
}
// 3. Apply pagination third (after filtering and sorting)
if (options.paging) {
const { page = 1, pageSize } = options.paging;
if (pageSize !== undefined) {
const paginationInjector = new SqlPaginationInjector_1.SqlPaginationInjector();
const paginationOptions = { page, pageSize };
// Ensure we have a SimpleSelectQuery for the injector
const simpleQuery = QueryBuilder_1.QueryBuilder.buildSimpleQuery(modifiedQuery);
modifiedQuery = paginationInjector.inject(simpleQuery, paginationOptions);
}
}
// 4. Apply column projection filters before any optimizer passes.
modifiedQuery = this.applyColumnFilters(modifiedQuery, options);
// 5. Prune supported truthful optional branches before structural optimizers.
const optionalConditionParameters = this.resolveOptionalConditionPruningParameters(options);
if (Object.keys(optionalConditionParameters).length > 0) {
modifiedQuery = (0, PruneOptionalConditionBranches_1.pruneOptionalConditionBranches)(modifiedQuery, optionalConditionParameters);
}
// 6. Remove unused LEFT JOINs when asked.
const effectiveSchemaInfo = (_a = options.schemaInfo) !== null && _a !== void 0 ? _a : this.defaultSchemaInfo;
if (options.removeUnusedLeftJoins && (effectiveSchemaInfo === null || effectiveSchemaInfo === void 0 ? void 0 : effectiveSchemaInfo.length)) {
modifiedQuery = (0, OptimizeUnusedLeftJoins_1.optimizeUnusedLeftJoinsToFixedPoint)(modifiedQuery, effectiveSchemaInfo);
}
// 7. Remove unused CTEs when requested.
if (options.removeUnusedCtes) {
modifiedQuery = (0, OptimizeUnusedLeftJoins_1.optimizeUnusedCtesToFixedPoint)(modifiedQuery);
}
return modifiedQuery;
}
resolveOptionalConditionPruningParameters(options) {
if (options.optionalConditionParameters) {
return options.optionalConditionParameters;
}
if (!options.optionalConditionParameterStates) {
return {};
}
const legacyParameters = {};
// Preserve backward compatibility for the state-map API while the value-based API becomes the primary entry point.
for (const [parameterName, state] of Object.entries(options.optionalConditionParameterStates)) {
legacyParameters[parameterName] = state === 'absent'
? null
: '__RAWSQL_OPTIONAL_CONDITION_PRESENT__';
}
return legacyParameters;
}
applyColumnFilters(query, options) {
const hasIncludeFilters = Array.isArray(options.includeColumns) && options.includeColumns.length > 0;
const hasExcludeFilters = Array.isArray(options.excludeColumns) && options.excludeColumns.length > 0;
if (!hasIncludeFilters && !hasExcludeFilters) {
return query;
}
if (hasIncludeFilters && hasExcludeFilters) {
throw new Error("includeColumns and excludeColumns cannot be used together.");
}
const simpleQuery = QueryBuilder_1.QueryBuilder.buildSimpleQuery(query);
const metadata = simpleQuery.selectClause.items.map(item => {
const name = this.getSelectItemName(item);
return {
item,
normalized: name ? this.normalizeColumnIdentifier(name) : null
};
});
const availableColumns = new Set(metadata
.map(entry => entry.normalized)
.filter((name) => name !== null));
const includeFilters = hasIncludeFilters ? this.normalizeColumnList(options.includeColumns) : null;
const excludeFilters = hasExcludeFilters ? this.normalizeColumnList(options.excludeColumns) : null;
const includeSet = includeFilters ? new Set(includeFilters.map(entry => entry.normalized)) : null;
const excludeSet = excludeFilters ? new Set(excludeFilters.map(entry => entry.normalized)) : null;
if (includeFilters) {
const missing = includeFilters.filter(entry => !availableColumns.has(entry.normalized));
if (missing.length > 0) {
throw new Error(`Column${missing.length === 1 ? "" : "s"} not found in SELECT clause: ${missing
.map(entry => `'${entry.original}'`)
.join(", ")}.`);
}
}
if (excludeFilters) {
const missing = excludeFilters.filter(entry => !availableColumns.has(entry.normalized));
if (missing.length > 0) {
throw new Error(`Column${missing.length === 1 ? "" : "s"} not found in SELECT clause: ${missing
.map(entry => `'${entry.original}'`)
.join(", ")}.`);
}
}
const filteredItems = metadata
.filter(entry => {
if (!entry.normalized) {
return true;
}
if (includeSet) {
return includeSet.has(entry.normalized);
}
if (excludeSet) {
return !excludeSet.has(entry.normalized);
}
return true;
})
.map(entry => entry.item);
if (filteredItems.length === 0) {
throw new Error("Column filtering removed every SELECT item.");
}
simpleQuery.selectClause.items = filteredItems;
return simpleQuery;
}
normalizeColumnList(columns) {
return columns.map(column => {
if (typeof column !== "string") {
throw new Error("Column filters must be strings.");
}
const trimmed = column.trim();
if (trimmed === "") {
throw new Error("Column filters must not be empty.");
}
return {
normalized: this.normalizeColumnIdentifier(trimmed),
original: trimmed
};
});
}
normalizeColumnIdentifier(value) {
return value.trim().toLowerCase();
}
getSelectItemName(item) {
if (item.identifier) {
return item.identifier.name;
}
if (item.value instanceof ValueComponent_1.ColumnReference) {
return item.value.column.name;
}
return null;
}
/**
* Legacy helper for binding existing named parameters without adding new runtime predicates.
* Dynamic WHERE-condition injection is no longer supported; use SSSQL scaffold/refresh instead.
*
* @param sqlContent Raw SQL string to parse and modify
* @param filter Named parameters to bind when they already exist in the SQL
* @returns Modified SelectQuery after binding existing named parameters
*/
buildFilteredQuery(sqlContent, filter) {
return this.buildQuery(sqlContent, { filter });
}
/**
* Builds a SelectQuery with only sorting applied.
* Convenience method for when you only need dynamic ORDER BY clauses.
*
* @param sqlContent Raw SQL string to parse and modify
* @param sort Sort conditions to apply
* @returns Modified SelectQuery with sort conditions applied
*/
buildSortedQuery(sqlContent, sort) {
return this.buildQuery(sqlContent, { sort });
} /**
* Builds a SelectQuery with only pagination applied.
* Convenience method for when you only need LIMIT/OFFSET clauses.
*
* @param sqlContent Raw SQL string to parse and modify
* @param paging Pagination options to apply
* @returns Modified SelectQuery with pagination applied
*/
buildPaginatedQuery(sqlContent, paging) {
return this.buildQuery(sqlContent, { paging });
}
/**
* Validates SQL content by attempting to parse it.
* Useful for testing SQL validity without applying any modifications.
*
* @param sqlContent Raw SQL string to validate
* @returns true if SQL is valid, throws error if invalid
* @throws Error if SQL cannot be parsed
*/
validateSql(sqlContent) {
try {
SelectQueryParser_1.SelectQueryParser.parse(sqlContent);
return true;
}
catch (error) {
throw new Error(`Invalid SQL: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
exports.DynamicQueryBuilder = DynamicQueryBuilder;
//# sourceMappingURL=DynamicQueryBuilder.js.map