@bitblit/ratchet-rdbms
Version:
Ratchet tooling for working with relational databases
352 lines • 15.7 kB
JavaScript
import { RequireRatchet } from '@bitblit/ratchet-common/lang/require-ratchet';
import { Logger } from '@bitblit/ratchet-common/logger/logger';
import { ErrorRatchet } from '@bitblit/ratchet-common/lang/error-ratchet';
import { StringRatchet } from '@bitblit/ratchet-common/lang/string-ratchet';
import { QueryBuilderResult } from './query-builder-result.js';
import { TransactionIsolationLevel } from '../model/transaction-isolation-level.js';
import { SortDirection } from '../model/sort-direction.js';
export class QueryBuilder {
static ALLOWED_SQL_CONSTRUCT = /^[a-z0-9_.`]+$/i;
queryProvider;
query;
meta = Object.freeze({});
sqlConstructs = {};
namedParams = {};
conditionals = {};
debugComment = '';
paginator;
debugAnnotateMode = false;
transactionIsolationLevel = TransactionIsolationLevel.Default;
constructor(queryProvider) {
this.queryProvider = queryProvider;
}
clone() {
const clone = new QueryBuilder(this.queryProvider);
if (this.query) {
clone.withBaseQuery(this.query);
}
clone.sqlConstructs = structuredClone(this.sqlConstructs);
clone.namedParams = structuredClone(this.namedParams);
clone.conditionals = structuredClone(this.conditionals);
clone.paginator = structuredClone(this.paginator);
clone.debugComment = this.debugComment;
clone.transactionIsolationLevel = this.transactionIsolationLevel;
return clone;
}
withTransactionIsolationLevel(level) {
this.transactionIsolationLevel = level;
return this;
}
withDebugComment(comment) {
this.debugComment = comment;
return this;
}
appendDebugComment(comment) {
this.debugComment = this.debugComment + comment;
return this;
}
withNamedQuery(queryPath) {
this.query = this.queryProvider.fetchQuery(queryPath);
if (!StringRatchet.trimToNull(this.query)) {
ErrorRatchet.throwFormattedErr('Requested query that does not exist : %s', queryPath);
}
this.meta = Object.freeze({ queryPath: queryPath });
this.withDebugComment(' ' + queryPath + ' ');
return this;
}
withBaseQuery(baseQuery) {
this.query = baseQuery;
}
withSqlConstruct(key, value) {
this.sqlConstructs[key] = value;
return this;
}
withSqlConstructs(params) {
this.sqlConstructs = Object.assign(this.sqlConstructs, params);
return this;
}
removeParam(key) {
delete this.namedParams[key];
return this;
}
paramNames() {
return Object.keys(this.namedParams);
}
withParam(key, value) {
this.namedParams[key] = value;
return this;
}
withParams(params) {
this.namedParams = Object.assign(this.namedParams, params ?? {});
return this;
}
withExpandedParam(keyPrefix, values, extendIfExists) {
const lengthParamName = keyPrefix + 'Length';
let oldSize = this.fetchCopyOfParam(lengthParamName) ?? 0;
if (oldSize > 0 && !extendIfExists) {
Logger.silly('Old item found and not extending - removing old params');
const toRemove = this.paramNames().filter((s) => s.startsWith(keyPrefix));
toRemove.forEach((s) => this.removeParam(s));
oldSize = 0;
}
this.withParam(lengthParamName, values.length + oldSize);
for (let i = 0; i < values.length; i++) {
const value = values[i];
if (typeof value === 'object' && !!value) {
for (const key of Object.keys(value)) {
const paramKey = keyPrefix + key.charAt(0).toUpperCase() + key.slice(1) + (i + oldSize);
this.withParam(paramKey, value[key]);
}
}
else {
const paramKey = keyPrefix + i;
this.withParam(paramKey, value);
}
}
return this;
}
withConditional(tag, state = true) {
this.conditionals[tag] = state;
return this;
}
withConditionals(params) {
this.conditionals = Object.assign(this.conditionals, params);
return this;
}
withPaginator(paginator) {
RequireRatchet.notNullOrUndefined(paginator, 'paginator');
RequireRatchet.notNullOrUndefined(paginator.cn, 'paginator.cn');
RequireRatchet.true(paginator.min || paginator.max || paginator.l, 'paginator must have some limit');
paginator.s = paginator.s ?? SortDirection.Asc;
this.paginator = paginator;
return this;
}
fetchCopyOfParam(paramName) {
return this.namedParams[paramName];
}
fetchCopyOfConditional(conditionalName) {
return this.conditionals[conditionalName];
}
containsParam(paramName) {
return this.namedParams[paramName] != undefined;
}
getDebugComment() {
return this.debugComment;
}
containsConditional(conditionalName) {
return this.conditionals[conditionalName] != undefined;
}
build() {
const build = this.clone();
return build.internalBuild(false);
}
buildUnfiltered() {
const builder = this.clone();
return builder.internalBuild(true);
}
internalBuild(unfiltered) {
this.applyQueryFragments();
this.applyConditionalBlocks();
this.applyRepeatBlocks();
this.applyPagination(unfiltered);
this.applySqlConstructs();
this.applyComments();
this.runQueryChecks();
this.stripNonAsciiParams();
return new QueryBuilderResult((this.query ?? '').trim(), this.namedParams, this.paginator, this.transactionIsolationLevel);
}
stripNonAsciiParams() {
const reduced = StringRatchet.stripNonAscii(JSON.stringify(this.namedParams));
this.namedParams = JSON.parse(reduced);
}
runQueryChecks() {
const quotedNamedParams = [...(this.query?.matchAll(/['"]:[A-z-]*['"]/gm) ?? [])];
if (quotedNamedParams.length > 0) {
Logger.warn('The resulting query contains quoted named params check this this is intended. Instances found: %s', quotedNamedParams.join(', '));
}
}
applyComments() {
if (this.debugComment.length && this.query) {
const firstSpaceIndex = this.query.indexOf(' ');
const comment = this.debugComment;
this.query = this.query.substring(0, firstSpaceIndex + 1) + `/*${comment}*/` + this.query.substring(firstSpaceIndex + 1);
}
}
applySqlConstructs() {
for (const key of Object.keys(this.sqlConstructs)) {
let value;
const val = this.sqlConstructs[key];
if (Array.isArray(val)) {
val.forEach((v) => {
if (typeof v !== 'string' || !v.match(QueryBuilder.ALLOWED_SQL_CONSTRUCT)) {
throw new Error(`sql construct entry ${v} is invalid value must be alphanumeric only.`);
}
});
value = val.join(', ');
}
else {
value = StringRatchet.safeString(val);
if (value.length > 0 && !value.match(QueryBuilder.ALLOWED_SQL_CONSTRUCT)) {
throw new Error(`sql construct ${value} is invalid value must be alphanumeric only.`);
}
}
const sqlReservedWords = ['update', 'insert', 'delete', 'drop', 'select'];
for (const word of sqlReservedWords) {
if (value.toLowerCase().includes(word)) {
throw new Error(`sql construct ${value} is invalid value must not contain reserved word ${word}.`);
}
}
const rawKey = `##sqlConstruct:${key}##`;
while (this.query?.includes(rawKey)) {
this.query = this.query.replace(rawKey, value);
}
}
}
applyRepeatBlocks() {
const startSymbol = '<repeat';
const endSymbol = '>';
while (true) {
const startIndex = this.query?.indexOf(startSymbol);
if (startIndex === -1 || !this.query || typeof startIndex !== 'number') {
return;
}
const endIndex = this.query.indexOf(endSymbol, startIndex);
if (endIndex == -1) {
throw new Error(`Invalid query when finding end symbol matching ${endSymbol} in ${this.query} check that you have closed all your tags correctly.`);
}
const content = this.query.substring(startIndex + startSymbol.length, endIndex).trim();
const countSymbol = 'count=';
let countParam = '';
const joinSymbol = 'join=';
let joinString;
const params = content.split(' ');
for (const param of params) {
if (param.includes(countSymbol)) {
countParam = param.substring(param.indexOf(countSymbol) + countSymbol.length);
}
if (param.includes(joinSymbol)) {
joinString = param.substring(param.indexOf(joinSymbol) + joinSymbol.length);
}
}
const endTag = `</repeat>`;
const endTagIndex = this.query.indexOf(endTag);
const repeatedContent = this.query.substring(endIndex + endSymbol.length, endTagIndex);
this.query = this.query.substring(0, startIndex) + this.query.substring(endTagIndex + endTag.length);
const count = this.namedParams[countParam.substring(1)];
for (let i = 0; i < count; i++) {
let indexedContent = repeatedContent;
if (joinString && i != 0) {
indexedContent += joinString;
}
let startParamTagIndex = indexedContent.indexOf(`::`);
while (startParamTagIndex != -1) {
const endParamTagIndex = indexedContent.indexOf(`::`, startParamTagIndex + 2);
if (endParamTagIndex == -1) {
throw new Error(`Invalid query when finding end symbol matching :: check that you have closed all your tags correctly. Query: ${this.query} `);
}
const param = indexedContent.substring(startParamTagIndex + 2, endParamTagIndex);
indexedContent = indexedContent.replace('::' + param + '::', ':' + param + i);
startParamTagIndex = indexedContent.indexOf(`::`);
}
this.query = this.query.substring(0, startIndex) + indexedContent + this.query.substring(startIndex);
}
}
}
applyQueryFragments() {
const startSymbol = '[[';
const endSymbol = ']]';
while (true) {
const startIndex = this.query?.indexOf(startSymbol);
if (startIndex == -1 || !this.query || typeof startIndex !== 'number') {
return;
}
const endIndex = this.query.indexOf(endSymbol, startIndex);
if (endIndex == -1) {
throw new Error(`Invalid query when finding end symbol matching ${endSymbol} in ${this.query} check that you have closed all your tags correctly.`);
}
const rawName = this.query.substring(startIndex + startSymbol.length, endIndex);
const namedQueryElement = this.queryProvider.fetchQuery(rawName);
if (!namedQueryElement) {
throw new Error(`Invalid query, query fragment ${rawName} not found in named queries.`);
}
this.query = this.query.replace(`[[${rawName}]]`, namedQueryElement);
}
}
applyPagination(unfiltered) {
const paginationRawKey = '##pagination##';
if (!unfiltered && this.paginator) {
const sortDirEnum = this.paginator.s == SortDirection.Desc ? SortDirection.Desc : SortDirection.Asc;
const sortDir = StringRatchet.safeString(sortDirEnum);
if (this.paginator.min || this.paginator.max) {
let wc = 'WHERE ##sqlConstruct:queryBuilderPaginatorWhere##';
this.withSqlConstruct('queryBuilderPaginatorWhere', this.paginator.cn);
if (this.paginator.min) {
wc += '>= :queryBuilderPaginatorWhereMin';
this.withParam('queryBuilderPaginatorWhereMin', this.paginator.min);
}
if (this.paginator.max) {
if (this.paginator.min) {
wc += ' AND ##sqlConstruct:queryBuilderPaginatorWhere##';
}
wc += '< :queryBuilderPaginatorWhereMax';
this.withParam('queryBuilderPaginatorWhereMax', this.paginator.max);
}
this.query += wc;
}
this.query += ` ORDER BY ##sqlConstruct:queryBuilderOrderBy## ${sortDir}`;
this.withSqlConstruct('queryBuilderOrderBy', this.paginator.cn);
if (this.paginator.l) {
this.query += ' LIMIT :queryBuilderLimit';
this.withParam('queryBuilderLimit', this.paginator.l);
}
}
if (unfiltered && this.query) {
const paginationSplitIndex = this.query.indexOf(paginationRawKey);
if (paginationSplitIndex != -1) {
this.query = 'SELECT COUNT(*) ' + this.query.substring(paginationSplitIndex + paginationRawKey.length);
}
}
while (this.query?.includes(paginationRawKey)) {
this.query = this.query.replace(paginationRawKey, '');
}
}
applyConditionalBlocks() {
const startSymbol = '<<';
const endSymbol = '>>';
while (true) {
const startIndex = this.query?.indexOf(startSymbol);
if (startIndex == -1 || !this.query || typeof startIndex !== 'number') {
return;
}
const endIndex = this.query.indexOf(endSymbol, startIndex);
if (endIndex == -1) {
throw new Error(`Invalid query when finding end symbol matching ${endSymbol} in ${this.query} check that you have closed all your tags correctly.`);
}
const rawTag = this.query.substring(startIndex + startSymbol.length, endIndex);
const tag = rawTag.replace(':', '');
const endTag = `<</${rawTag}>>`;
const endTagIndex = this.query.indexOf(endTag);
if (endTagIndex == -1) {
throw new Error(`Invalid query when finding conditional end tag matching ${endTag} in ${this.query} check that your query contains an exact match of this tag.`);
}
let replacement = this.query.substring(endIndex + endSymbol.length, endTagIndex);
if (rawTag.startsWith(':')) {
const param = this.namedParams[tag];
if (param == null || (Array.isArray(param) && param.length == 0)) {
replacement = '';
}
}
else {
const conditional = this.conditionals[tag.replace('!', '')];
if (conditional == undefined || conditional == tag.startsWith('!')) {
replacement = '';
}
}
if (this.debugAnnotateMode) {
replacement = '/* conditional ' + tag + '*/';
}
this.query = this.query.substring(0, startIndex) + replacement + this.query.substring(endTagIndex + endTag.length);
}
}
}
//# sourceMappingURL=query-builder.js.map