x2node-dbos
Version:
SQL database operations.
734 lines (653 loc) • 19.1 kB
JavaScript
'use strict';
const common = require('x2node-common');
const ValueExpression = require('./value-expression.js');
const placeholders = require('./placeholders.js');
const queryTreeBuilder = require('./query-tree-builder.js');
const Translatable = require('./translatable.js');
/**
* Filter base class.
*
* @protected
* @memberof module:x2node-dbos
* @inner
* @extends module:x2node-dbos~Translatable
* @abstract
*/
class RecordsFilter extends Translatable {
/**
* Create new filter base.
*/
constructor() {
super();
}
/**
* Tell if this specification is empty (effectively a noop).
*
* @returns {boolean} <code>true</code> if empty.
*/
isEmpty() { return false; }
/**
* Tell if this specification included in a logical junction needs to be
* supprouned in parenthesis.
*
* @param {string} juncType Type of the junction, in which the element is
* being included.
* @returns {boolean} <code>true</code> if needs to be surrounded in
* parenthesis.
*/
needsParen() { return false; }
/**
* Tell if has collection tests.
*
* @function module:x2node-dbos~RecordsFilter#hasCollectionTests
* @returns {boolean} <code>true</code> if has collection tests.
*/
/**
* Form logical conjunction with another filter. Neither this nor the other
* filter are modified.
*
* @param {module:x2node-dbos~RecordsFilter} otherFilter The other filter.
* @returns {module:x2node-dbos~RecordsFilter} Resulting conjunction.
*/
conjoin(otherFilter) {
return (new RecordsFilterJunction('AND', false))
.addElement(this)
.addElement(otherFilter);
}
}
/**
* Filter logical junction.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @extends module:x2node-dbos~RecordsFilter
*/
class RecordsFilterJunction extends RecordsFilter {
/**
* Create new junction.
*
* @param {string} juncType Either "AND" or "OR".
* @param {boolean} invert <code>true</code> to negate the whole junction.
*/
constructor(juncType, invert) {
super();
this._juncType = juncType;
this._invert = invert;
}
/**
* Add element to the junction.
*
* @param {module:x2node-dbos~RecordsFilter} element Element to add to the
* junction. If the element is empty, it's ignored.
* @returns {module:x2node-dbos~RecordsFilterJunction} This junction.
*/
addElement(element) {
if (!element.isEmpty()) {
if (!this._elements)
this._elements = new Array();
this._elements.push(element);
element.usedPropertyPaths.forEach(p => {
this._usedPropertyPaths.add(p);
});
}
return this;
}
// empty test implementation
isEmpty() {
return (!this._elements || (this._elements.length === 0));
}
// needs parenthesis implementation
needsParen(juncType) {
if (this.isEmpty()) // should never happend anyway
return false;
if (this._invert)
return false;
if (this._elements.length === 1)
return this._elements[0].needsParen(juncType);
return (this._juncType !== juncType);
}
// having collection tests implementation
hasCollectionTests() {
if (this.isEmpty()) // should never happend anyway
return false;
for (let e of this._elements)
if (e.hasCollectionTests())
return true;
return false;
}
// translation implementation
translate(ctx) {
if (this.isEmpty())
throw new Error(
'Internal X2 error: translating empty logical junction.');
let juncSql;
if (this._elements.length === 1) {
juncSql = this._elements[0].translate(ctx);
} else {
juncSql = this._elements.map(element => {
const elementSql = element.translate(ctx);
return (
element.needsParen(this._juncType) ?
'(' + elementSql + ')' : elementSql
);
}).join(' ' + this._juncType + ' ');
}
return (this._invert ? 'NOT (' + juncSql + ')' : juncSql);
}
}
/**
* Filter single value expression test.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @extends module:x2node-dbos~RecordsFilter
*/
class RecordsFilterValueTest extends RecordsFilter {
/**
* Create new test.
*
* @param {module:x2node-dbos~ValueExpression} valueExpr Value expression
* to test.
* @param {string} testType Test type.
* @param {boolean} invert <code>true</code> to negate the test.
* @param {Array} [testParams] Test type specific number of test parameters.
* Each parameter can be a value expression, a filter parameter or a value.
*/
constructor(valueExpr, testType, invert, testParams) {
super();
this._valueExpr = valueExpr;
this._testType = testType;
this._invert = invert;
this._testParams = testParams;
valueExpr.usedPropertyPaths.forEach(p => {
this._usedPropertyPaths.add(p);
});
if (testParams) {
for (let i = 0, len = testParams.length; i < len; i++) {
const testParam = testParams[i];
if (testParam instanceof ValueExpression)
testParam.usedPropertyPaths.forEach(p => {
this._usedPropertyPaths.add(p);
});
}
}
}
// report as not a collection test
hasCollectionTests() { return false; }
// translation implementation
translate(ctx) {
const valSql = this._valueExpr.translate(ctx);
function testParamSql(param, litValueFunc, exprValueFunc) {
if (param instanceof ValueExpression) {
const exprSql = param.translate(ctx);
return (exprValueFunc ? exprValueFunc(exprSql) : exprSql);
}
if (placeholders.isParam(param))
return ctx.paramsHandler.addParam(param.name, litValueFunc);
return ctx.paramsHandler.paramValueToSql(
ctx.dbDriver, param, litValueFunc);
}
switch (this._testType) {
case 'eq':
return valSql + ' = ' + testParamSql(this._testParams[0]);
case 'ne':
return valSql + ' <> ' + testParamSql(this._testParams[0]);
case 'ge':
return valSql + ' >= ' + testParamSql(this._testParams[0]);
case 'le':
return valSql + ' <= ' + testParamSql(this._testParams[0]);
case 'gt':
return valSql + ' > ' + testParamSql(this._testParams[0]);
case 'lt':
return valSql + ' < ' + testParamSql(this._testParams[0]);
case 'in':
return valSql + (this._invert ? ' NOT' : '') +
' IN (' + this._testParams.map(p => testParamSql(p)).join(', ') +
')';
case 'between':
return valSql + (this._invert ? ' NOT' : '') +
' BETWEEN ' + testParamSql(this._testParams[0]) +
' AND ' + testParamSql(this._testParams[1]);
case 'contains':
case 'containsi':
return ctx.dbDriver.patternMatch(
valSql, testParamSql(
this._testParams[0],
lit =>
'%' + ctx.dbDriver.safeLikePatternFromString(lit) + '%',
expr =>
ctx.dbDriver.nullableConcat(
ctx.dbDriver.stringLiteral('%'),
ctx.dbDriver.safeLikePatternFromExpr(expr),
ctx.dbDriver.stringLiteral('%')
)
),
this._invert, !this._testType.endsWith('i'));
case 'starts':
case 'startsi':
return ctx.dbDriver.patternMatch(
valSql, testParamSql(
this._testParams[0],
lit =>
ctx.dbDriver.safeLikePatternFromString(lit) + '%',
expr =>
ctx.dbDriver.nullableConcat(
ctx.dbDriver.safeLikePatternFromExpr(expr),
ctx.dbDriver.stringLiteral('%')
)
),
this._invert, !this._testType.endsWith('i'));
case 'matches':
case 'matchesi':
return ctx.dbDriver.regexpMatch(
valSql, testParamSql(this._testParams[0]),
this._invert, !this._testType.endsWith('i'));
case 'empty':
return valSql + ' IS' + (this._invert ? ' NOT' : '') + ' NULL';
}
}
}
/**
* Filter collection property test.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @extends module:x2node-dbos~RecordsFilter
*/
class RecordsFilterCollectionTest extends RecordsFilter {
/**
* Create new test.
*
* @param {module:x2node-records~RecordTypesLibrary} recordTypes Record types
* library.
* @param {string} colBasePropPath Path of the property at the collection
* reference base.
* @param {string} testType Test type, which is either "empty" or "count".
* @param {boolean} invert <code>true</code> to negate the test.
* @param {Array} testParams Test type specific number of test parameters.
* @param {module:x2node-dbos~ValueExpressionContext} valueExprCtx Value
* expression context for expressions in the collection filter.
* @param {Array[]} [filterSpec] Raw collection filter specification, if any.
*/
constructor(
recordTypes, colBasePropPath,
testType, invert, testParams,
valueExprCtx, filterSpec) {
super();
// save basics needed for the translation
this._colPath = valueExprCtx.basePath;
this._colBasePath = colBasePropPath;
this._testType = testType;
this._invert = invert;
this._testParams = testParams;
// process collection filter
if (filterSpec) {
// build the collection filter
this._colFilter = buildFilter(
recordTypes, valueExprCtx, [ ':and', filterSpec ]);
// find all properties used outside the collection context
const colBasePathPrefix = this._colBasePath + '.';
this._colFilter.usedPropertyPaths.forEach(propPath => {
if (!propPath.startsWith(colBasePathPrefix))
this._usedPropertyPaths.add(propPath);
});
}
}
// report as collection test
hasCollectionTests() { return true; }
// translation implementation
translate(ctx) {
// build properties tree for the subquery
const propsTree = ctx.buildSubqueryPropsTree(
this._colPath,
(this._colFilter ? this._colFilter.usedPropertyPaths : []),
'where'
);
// build the subquery tree
const subqueryTree = queryTreeBuilder.forExistsSubquery(
ctx, propsTree, this._colBasePath);
// build subquery
const subqueryBuilder = new Object();
const subqueryCtx = subqueryTree.getTopTranslationContext(
ctx.paramsHandler);
subqueryTree.walk(subqueryCtx, (propNode, tableDesc, tableChain) => {
if (tableChain.length === 0)
return;
if (tableChain.length === 1) {
subqueryBuilder.where = tableDesc.basicJoinCondition;
if (this._colFilter) {
const filterSql = ctx.rebaseTranslatable(
this._colFilter).translate(subqueryCtx);
subqueryBuilder.where += ' AND ' + (
this._colFilter.needsParen('AND') ?
'(' + filterSql + ')' : filterSql);
}
}
if (subqueryBuilder.from) {
subqueryBuilder.from +=
' INNER JOIN ' + tableDesc.tableName + ' AS ' +
tableDesc.tableAlias + ' ON ' + tableDesc.joinCondition;
} else {
subqueryBuilder.from = tableDesc.tableName + ' AS ' +
tableDesc.tableAlias;
}
});
const subqueryFrom = 'FROM ' + subqueryBuilder.from +
' WHERE ' + subqueryBuilder.where;
// return the test SQL depending on the test type
switch (this._testType) {
case 'count':
return '(SELECT COUNT(*) ' + subqueryFrom + ') ' +
(this._invert ? '<> ' : '= ') + String(this._testParams[0]);
case 'empty':
return (this._invert ? '' : 'NOT ') +
'EXISTS (SELECT 1 ' + subqueryFrom + ')';
}
}
}
/**
* Parse records filter specification and build the filter.
*
* @protected
* @param {module:x2node-records~RecordTypesLibrary} recordTypes Record types
* library.
* @param {module:x2node-dbos~ValueExpressionContext} valueExprCtx Value
* expression context to use.
* @param {Array} testSpec Single raw filter test specification.
* @returns {module:x2node-dbos~RecordsFilter} The filter. May return
* <code>undefined</code> if the provided specification results in no filter.
* @throws {module:x2node-common.X2UsageError} If the specification is invalid.
*/
function buildFilter(recordTypes, valueExprCtx, testSpec) {
// error function
const error = msg => new common.X2UsageError(
'Invalid filter specification' + (
valueExprCtx.basePath.length > 0 ?
' on ' + valueExprCtx.basePath : '') + ': ' + msg
);
// validate test specification array
if (!Array.isArray(testSpec) || (testSpec.length === 0))
throw error(
'filter test specification must be an array and must not be empty.');
// parse the predicate
const pred = testSpec[0];
if ((typeof pred) !== 'string')
throw error(`invalid type for predicate "${pred}".`);
const predParts = pred.match(
/^\s*(?:(?::(!?\w+)\s*)|([^:=\s].*?)\s*(?:=>\s*(!?\w+)\s*)?)$/i);
if (predParts === null)
throw error(`predicate "${pred}" has invalid syntax.`);
// check if junction
if (predParts[1] !== undefined) {
// validate junction conditions array
if (!Array.isArray(testSpec[1]) || (testSpec.length > 2))
throw error(
'logical junction must be followed by exactly one' +
' array of nested tests.');
// determine type of the logical junction
let juncType, invert;
switch (predParts[1].toLowerCase()) {
case 'or':
case 'any':
case '!none':
juncType = 'OR'; invert = false;
break;
case '!or':
case '!any':
case 'none':
juncType = 'OR'; invert = true;
break;
case 'and':
case 'all':
juncType = 'AND'; invert = false;
break;
case '!and':
case '!all':
juncType = 'AND'; invert = true;
break;
default:
throw error(`unknown junction type "${predParts[1]}".`);
}
// create junction test object
const junc = new RecordsFilterJunction(juncType, invert);
// add junction elements
testSpec[1].forEach(nestedTestSpec => {
const nestedTest = buildFilter(
recordTypes, valueExprCtx, nestedTestSpec);
if (nestedTest)
junc.addElement(nestedTest);
});
// return the result if not empty junction
return (junc.isEmpty() ? undefined : junc);
}
// test, not a junction:
// parse the value expression
const valueExpr = new ValueExpression(valueExprCtx, predParts[2]);
// vars for the test
let testType, invert = false, testParams;
// check if collection test
const singlePropValueExprCtx = (
valueExpr.isSinglePropRef() &&
valueExprCtx.getRelativeContext(predParts[2]));
if (singlePropValueExprCtx &&
!singlePropValueExprCtx.basePropertyDesc.isScalar()) {
// parse and validate the test
let colFilterSpecInd = 1;
const rawTestType = (
predParts[3] !== undefined ? predParts[3] : '!empty');
/* eslint-disable no-fallthrough */
switch (rawTestType.toLowerCase()) {
case '!empty':
invert = true;
case 'empty':
testType = 'empty';
break;
case '!count':
invert = true;
case 'count':
testType = 'count';
testParams = [ testSpec[1] ];
if (!Number.isInteger(testParams[0]))
throw error(
`test "${rawTestType}" expects an integer number argument.`);
colFilterSpecInd = 2;
break;
default:
throw error(
`invalid collection test "${pred}" as it may only be "empty"` +
', "!empty", "count" or "!count".');
}
/* eslint-enable no-fallthrough */
// validate test arguments
const colFilterSpec = testSpec[colFilterSpecInd];
if ((testSpec.length > colFilterSpecInd + 1) || (
(colFilterSpec !== undefined) && !Array.isArray(colFilterSpec)))
throw error(
'collection test may only have none or a single' +
' collection filter argument and it must be an array.');
// create and return collection test
return new RecordsFilterCollectionTest(
recordTypes,
valueExprCtx.normalizePropertyRef(
predParts[2].match(/^((?:\^\.)*[^.]+)/)[1]),
testType, invert, testParams,
singlePropValueExprCtx,
colFilterSpec
);
}
// single value test:
// functions for extracting and validating test parameters
function getSingleTestParam() {
const v = testSpec[1];
if ((testSpec.length > 2) || (v === undefined) ||
(v === null) || Array.isArray(v))
throw error(
`test "${rawTestType}" expects a single non-null,` +
` non-array argument.`);
return [ v ];
}
function getTwoTestParams() {
let v1, v2;
switch (testSpec.length) {
case 2:
if (Array.isArray(testSpec[1]) && (testSpec[1].length === 2)) {
v1 = testSpec[1][0];
v2 = testSpec[1][1];
}
break;
case 3:
v1 = testSpec[1];
v2 = testSpec[2];
}
if ((v1 === undefined) || (v2 === undefined) ||
(v1 === null) || (v2 === null) ||
Array.isArray(v1) || Array.isArray(v2))
throw error(
`test "${rawTestType}" expects two non-null,` +
` non-array arguments.`);
return [ v1, v2 ];
}
function getListTestParams() {
const f = (a, v) => {
if ((v === null) || (v === undefined))
throw error(
`test "${rawTestType}" expects a list of non-null` +
` arguments.`);
if (Array.isArray(v))
v.forEach(vv => { f(a, vv); });
else
a.push(v);
return a;
};
const a = testSpec.slice(1).reduce(f, new Array());
if (a.length === 0)
throw error(
`test "${rawTestType}" expects a list of non-null arguments.`);
return a;
}
// determine the test type
const rawTestType = (
predParts[3] ? predParts[3] : (
testSpec.length > 1 ? 'is' : 'present'));
/* eslint-disable no-fallthrough */
switch (rawTestType.toLowerCase()) {
case 'is':
case 'eq':
testType = 'eq';
testParams = getSingleTestParam();
break;
case 'not':
case 'ne':
case '!eq':
testType = 'ne';
testParams = getSingleTestParam();
break;
case 'min':
case 'ge':
case '!lt':
testType = 'ge';
testParams = getSingleTestParam();
break;
case 'max':
case 'le':
case '!gt':
testType = 'le';
testParams = getSingleTestParam();
break;
case 'gt':
testType = 'gt';
testParams = getSingleTestParam();
break;
case 'lt':
testType = 'lt';
testParams = getSingleTestParam();
break;
case '!in':
case '!oneof':
invert = true;
case 'in':
case 'oneof':
case 'alt':
testType = 'in';
testParams = getListTestParams();
break;
case '!between':
invert = true;
case 'between':
testType = 'between';
testParams = getTwoTestParams();
break;
case '!contains':
invert = true;
case 'contains':
testType = 'contains';
testParams = getSingleTestParam();
break;
case '!containsi':
case '!substring':
invert = true;
case 'containsi':
case 'substring':
testType = 'containsi';
testParams = getSingleTestParam();
break;
case '!starts':
invert = true;
case 'starts':
testType = 'starts';
testParams = getSingleTestParam();
break;
case '!startsi':
case '!prefix':
invert = true;
case 'startsi':
case 'prefix':
testType = 'startsi';
testParams = getSingleTestParam();
break;
case '!matches':
invert = true;
case 'matches':
testType = 'matches';
testParams = getSingleTestParam();
break;
case '!matchesi':
case '!pattern':
case '!re':
invert = true;
case 'matchesi':
case 'pattern':
case 're':
testType = 'matchesi';
testParams = getSingleTestParam();
break;
case '!empty':
case 'present':
invert = true;
case 'empty':
testType = 'empty';
if (testSpec.length > 1)
throw error(`test "${rawTestType}" expects no arguments.`);
break;
default:
throw error(`unknown test "${rawTestType}".`);
}
/* eslint-enable no-fallthrough */
// compile value expressions in test params if any
if (testParams) {
for (let i = 0, len = testParams.length; i < len; i++) {
const testParam = testParams[i];
if (placeholders.isExpr(testParam)) {
testParams[i] = new ValueExpression(
valueExprCtx, testParam.expr);
}
}
}
// create and return the resulting test
return new RecordsFilterValueTest(valueExpr, testType, invert, testParams);
}
// export the builder function
exports.buildFilter = buildFilter;