UNPKG

@bitblit/ratchet-rdbms

Version:

Ratchet tooling for working with relational databases

352 lines 15.7 kB
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