docs: 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);
  }