Source: filterfunctions.js

import { asc, desc } from './basefilter.js';

/* example use

	// create a couple filter expressions
	customerFirstName = eq("FirstName","A*")
	customerCountry = eq("Country","Brazil")
	
	q1 = new DataDiscoveryFilter(customerFirstName)												// create the filter object for the first resource, with an initial filter expression if desired
	q1 = q1.addFilter(customerCountry)															// add the second filter expression, previously stored in a variable
		.addFilter(any("City","Brasília","São Paulo"))											// add another filter expression in-line
		.join("Invoice")																		// join a related resource
		.addFilter( and(gte("InvoiceDate","2011-01-01"), eq("BillingCountry","Brazil") ))		// add a nested filter expression in-line
		.join("InvoiceLine")																	// join a related resource
		.join("Track")																			// join a related resource - last context
		.sort("Name","UnitPrice")																// sort results by these columns
		
	qstring = q1.str()																	// retrieve the constructed query string
*/

 	// output:
	// FirstName/A*/Country/Brazil/City/Brasília|São Paulo/Invoice/InvoiceDate/2011-01-01../BillingCountry/Brazil/InvoiceLine/Track?sort=Name,UnitPrice


// *** expression builders - any/eq/between/gte/lte build expressions

const SDB_FILTER_ERR_INVALID_COL_NAME = "Invalid column name parameter: must be non-empty string; cannot contain spaces; cannot begin with a number; cannot contain '/'; cannot contain '..'";
const SDB_FILTER_ERR_INVALID_NUM_ARGS = 'Invalid number of filter parameters';
const SDB_FILTER_ERR_INVALID_TYPE = 'Invalid data type for value, must be string or number';
const SDB_FILTER_ERR_INVALID_TYPE_NULL = 'Invalid data type for value, must be string, number, or null';
const SDB_FILTER_ERR_INVALID_VALUE_EMPTY_STRING = 'String values cannot be empty';
const SDB_FILTER_ERR_INVALID_VALUE_SLASH = "String values cannot contain '/' (use __ or set placeholder query parameter for values that contain '/') ";
const SDB_FILTER_ERR_INVALID_COMPARE_TYPE ='Range value data types must match';
const SDB_FILTER_ERR_INVALID_RANGE = 'Invalid range values';
const SDB_FILTER_ERR_INVALID_RANGE_VALUE_DOTS = "Range values cannot contain '..'";
const SDB_FILTER_ERR_NO_COL_FOUND = 'No column detected in parameter (string must contain at least one of: {col/}';


// SDB_SEPARATOR is used to separate values in the column/values string returned by any().
// SDB_SEPARATOR is imported into the DataDiscoveryFilter module, where a filter string
// that contains it is automatically detected, and it is replaced with the DataDiscoveryFilter 
// separator string value.  The advantage of this approach is that a user only has to 
// set a unique a separator in the DataDiscoveryFilter module when they are passing values
// that contain ',' characters to the any() function.  SDB_SEPARATOR is a very unique value
// by default, but can be changed using chgPlaceHolder(',',value) if desired.

// default placeholder values
const PLACEHOLDER_DEFAULTS = {
	valueSeparator : '|SDBSEP|',
	nullStr : '<null>'
}

let SDB_SEPARATOR = PLACEHOLDER_DEFAULTS['valueSeparator'];
let SDB_NULLSTR = PLACEHOLDER_DEFAULTS['nullStr'];

/**
* Change placeholder values for `null` and `,` in filter functions.
* eg: `chgPlaceHolder(null,'*nullValue*')` - updates the null placeholder to `*nullValue*`
*
* Calling without parameters will reset all placeholders to their default values.
* A single placeholder parameter w/o a value will reset only that parameter to its default value.
*
* Note that changing the `null` placeholder with this function will automatically set the `nullStr`
* query string parameter for any `BaseFilter`, `DataDiscoveryFilter` and `SQLPassThruFilter` 
* objects created afte invoking the function.
*
* *It is not recommended to change the `,` separator unless you are encountering issues creating filters
* with the default value `|SDBSEP|`*

* @param {any} placeHolder - placeholder to update
* @param {string} value - new value for the placeholder
* @returns {any} updated placeholder value
*/
function chgPlaceHolder(placeHolder, value) {
	
	function setPlaceHolders(placeHolder, value) {
		switch(placeHolder) {
			case ',':
				return SDB_SEPARATOR = value === undefined ? PLACEHOLDER_DEFAULTS['valueSeparator'] : value;
			case null:
				return SDB_NULLSTR = value === undefined ? PLACEHOLDER_DEFAULTS['nullStr'] : value;
			default:
				SDB_SEPARATOR = PLACEHOLDER_DEFAULTS['valueSeparator'];
				SDB_NULLSTR = PLACEHOLDER_DEFAULTS['nullStr'];
				return [[',', SDB_SEPARATOR], [null, SDB_NULLSTR]];
		}
	}

	if (arguments.length === 0) {
		return setPlaceHolders();
	}

	if (arguments.length === 1) {
		return setPlaceHolders(placeHolder);
	}

	if (typeof(value) !== 'string') {
		throw TypeError('Placeholder value must be a string') 
	}
	
	if (!isNaN(parseInt(value[0])) || value.indexOf(' ') > -1 || value.indexOf('/') > -1 || value.indexOf('..') > -1) {
		throw SyntaxError("Placeholder value cannot contain spaces/slash or '..' sequences") 
	}
	
	return setPlaceHolders(placeHolder, value);
};



// sanity checks for column names
function validateColumnName(col) {
	if (typeof(col) !== 'string') {
		throw TypeError(SDB_FILTER_ERR_INVALID_COL_NAME);
	}
	
	if (!isNaN(parseInt(col[0])) || col.indexOf(' ') > -1 || col.trim().length < 1) {
		throw SyntaxError(SDB_FILTER_ERR_INVALID_COL_NAME);
	}
	
	if (col.indexOf('/') > -1) {								
		throw SyntaxError(SDB_FILTER_ERR_INVALID_COL_NAME);
	}

	if (col.indexOf('..') > -1) {
		throw SyntaxError(SDB_FILTER_ERR_INVALID_COL_NAME);
	}

}


/**
* Single value equality filter - column value equals parameter value
*
* eg: `eq("CustomerId",1)`
*
* @param {string} col - name of column to apply filter to
* @param {string | number} value - parameter containing the value to filter by
* @returns {string} constructed URL segment for filtering the given column
*/
function eq(col, value) {
	if (arguments.length !== 2) {
		throw ReferenceError(SDB_FILTER_ERR_INVALID_NUM_ARGS);
	}
	validateColumnName(col);

	value = value === null ? SDB_NULLSTR : value;

	if (typeof(value) !== 'number' && typeof(value) !== 'string') {
		throw TypeError(SDB_FILTER_ERR_INVALID_TYPE_NULL); 
	}

	if (typeof(value) === 'string' && value.indexOf('/') > -1) {
		throw SyntaxError(SDB_FILTER_ERR_INVALID_VALUE_SLASH);
	}
	
	return any(col, value);
}


/**
* Multi-value equality filter - column value equals any of the parameter values
*
* eg: `any("CustomerId",1,2,3)`
*
* @param {string} col - name of column to apply filter to
* @param {...string | ...number} values - comma delimited list of values to filter by (can be mixed strings/numbers)
* @returns {string} constructed URL segment for filtering the given column
*/
function any(col, ...values) {
	if (arguments.length < 2) {
		throw ReferenceError(SDB_FILTER_ERR_INVALID_NUM_ARGS);
	}
	validateColumnName(col);

	let s = `${col}/`;
	for (let [i, v] of values.entries()) {
		
		v = v === null ? SDB_NULLSTR : v;

		if (typeof(v) !== 'number' && typeof(v) !== 'string') {
			throw TypeError(SDB_FILTER_ERR_INVALID_TYPE_NULL + ` (parameter ${i+2})`);
		}		

		if (typeof(v) === 'string' && v.indexOf('/') > -1) {
			throw SyntaxError(SDB_FILTER_ERR_INVALID_VALUE_SLASH + ` (parameter ${i+2})`);
		}
			
		/** uncomment to disable empty strings for values **/
		// if (typeof(v) === 'string' && v.trim().length === 0) {
		// 	throw TypeError(SDB_FILTER_ERR_INVALID_VALUE_EMPTY_STRING + ` (parameter ${i+2})`); 
		// }
			
		else {
			// URL encode commas if desired
			// if (typeof(v) === 'string') {
			// 	v = v.replaceAll(',' , '%2C');
			// }
			
			// separate values with the unique separator string
			s += `${v}${SDB_SEPARATOR}`
		}
	}

	return s.slice(0, (0 - SDB_SEPARATOR.length) )
}


/**
* Range filter - column value is between the parameter values (inclusive)
*
* eg: 
* `between("CustomerId",1,10)` - returns range string with column name
* `between(4,8)` - returns range string w/o column name
*
* @param {string} arg1 - name of column to apply filter to, or lower bound
* @param {string | number | null} lb - lower bound value of the range, or upper bound
* @param {string | number | null} ub - upper bound value of the range
* @returns {string} constructed URL segment for filtering the given column, or range string
*/
function between(arg1, lb = null, ub = null) {

	const nullValues = [null,undefined];
	let hasColumnName = false;

	if (arguments.length < 2 || arguments.length > 3 ) { 
		throw ReferenceError(SDB_FILTER_ERR_INVALID_NUM_ARGS);
	}
	
	if (arguments.length == 2) {
		ub = lb;
		lb = arg1;
	}

	else if (arguments.length == 3) {
		validateColumnName(arg1);
		hasColumnName = true;
	}

	const v = [lb, ub];
	// if both values are null/undefined - error
	if ( nullValues.includes(v[0]) && nullValues.includes(v[1]) ) {
		throw ReferenceError(SDB_FILTER_ERR_INVALID_RANGE); 
	}
	
	// if both values are present but are of different types - error
	if ( !nullValues.includes(v[0]) && !nullValues.includes(v[1]) ) {
		if ( typeof(v[0]) !== typeof(v[1]) ) {
			throw TypeError(SDB_FILTER_ERR_INVALID_COMPARE_TYPE); 
		}
	}
	
	const ranges = []
	for (let value of v)
	{
		// if either value is an empty string - error
		if (typeof(value) === 'string' && value.trim().length < 1) {
			throw SyntaxError(SDB_FILTER_ERR_INVALID_VALUE_EMPTY_STRING); 
		}

		// if either value contains '/' - error
		if (typeof(value) === 'string' && value.indexOf('/') > -1) {
			throw SyntaxError(SDB_FILTER_ERR_INVALID_VALUE_SLASH);
		}	

		// if either value contains .. - error
		if (typeof(value) === 'string' && value.indexOf('..') > -1) {
			throw SyntaxError(SDB_FILTER_ERR_INVALID_RANGE_VALUE_DOTS);
		}

		value = nullValues.includes(value) ? '' : value;

		if (typeof(value) !== 'number' && typeof(value) !== 'string') { 
			throw TypeError(SDB_FILTER_ERR_INVALID_TYPE); 
		}

		ranges.push(value);
	}

	[lb, ub] = ranges;
	return hasColumnName ? `${arg1}/${lb}..${ub}` : `${lb}..${ub}`
}

/**
* Greater-than-equal filter - column value is greater than or equal to the parameter value
* Wrapper for `between()`
*
* eg: 
* `gte("CustomerId",10)` - returns range string with column name
* `gte(10)` - returns range string w/o column
*
* @param {string} arg1 - name of column to apply filter to, or lower bound
* @param {string | number } lb - lower bound value of the filter when column name given
* @returns {string} constructed URL segment for filtering the given column, or bare range string
*/

function gte(arg1, lb) {
	if (arguments.length === 0 || arguments.length > 2) {
		throw ReferenceError(SDB_FILTER_ERR_INVALID_NUM_ARGS);
	}

	if (arguments.length == 1) {
		return between(arg1, null);
	}
	
	return between(arg1, lb, null);

}

/**
* Less-than-equal filter - column value is less than or equal to the parameter value
* Wrapper for `between()`
*
* eg: 
* `lte("CustomerId",10)` - returns range string with column name
* `lte(10)` - returns range string w/o column
*
* @param {string} arg1 - name of column to apply filter to, or upper bound
* @param {string | number } ub - upper bound value of the filter when column name given
* @returns {string} constructed URL segment for filtering the given column, or bare range string
*/

function lte(arg1, ub) {
	if (arguments.length === 0 || arguments.length > 2) {
		throw ReferenceError(SDB_FILTER_ERR_INVALID_NUM_ARGS);
	}

	if (arguments.length == 1) {
		return between(null, arg1);
	}
	
	return between(arg1, null, ub);
}


// *** expression modifiers - operate on expressions created by expression builders

/**
* Negates a filter expression - can nest `any, eq, and, between, gte, lte` expressions
*
* eg: `not(eq("CustomerId",1))`
*
* @param {string} colFilter - a string containing column filter (can contain concatenated filters)
* @returns {string} negated version of URL segment for filtering the given column(s)
*/
function not(colFilter) { 
	if (arguments.length !== 1)	{ 
		throw ReferenceError(SDB_FILTER_ERR_INVALID_NUM_ARGS);
	}

	if (typeof(colFilter) !== 'string' || colFilter.trim().length < 1) {
		throw TypeError(SDB_FILTER_ERR_INVALID_TYPE);
	}
	
	if (colFilter.indexOf('/') === -1) {
		throw SyntaxError(SDB_FILTER_ERR_NO_COL_FOUND);
	}

	return `~${colFilter}`;
}


/**
* Concatenates filter expressions - can nest `any, eq, between, gte, lte` expressions
*
* eg: `and( eq("FirstName","David")`, `any("City","Vancouver","Edmonton") )`
*
* @param {...string} colFilters - comma delimited list of strings containing column filters
* @returns {string} concatenated URL segment containing multiple column filters
*/
function and(...colFilters) {

	if (colFilters.length === 0) {
		throw ReferenceError(SDB_FILTER_ERR_INVALID_NUM_ARGS);
	}

	let s = '';
	for (const [i,v] of colFilters.entries()) {
		if (typeof(v) !== 'string' || v.trim().length < 1) {
			throw TypeError(SDB_FILTER_ERR_INVALID_TYPE + ` (parameter ${i+1})`);
		}		

		if (v.indexOf('/') === -1) {
			throw SyntaxError(SDB_FILTER_ERR_NO_COL_FOUND);
		}

		s += `${v}/`;

	}

	return s.slice(0,-1)
}

export { eq, any, between, gte, lte, not, and }
export { chgPlaceHolder }
export { asc, desc }
export { SDB_SEPARATOR, SDB_NULLSTR }

// for testing only
export { SDB_FILTER_ERR_INVALID_COL_NAME, SDB_FILTER_ERR_INVALID_NUM_ARGS, SDB_FILTER_ERR_INVALID_TYPE, SDB_FILTER_ERR_INVALID_VALUE_EMPTY_STRING, 
    SDB_FILTER_ERR_INVALID_VALUE_SLASH, SDB_FILTER_ERR_INVALID_COMPARE_TYPE, SDB_FILTER_ERR_INVALID_RANGE, SDB_FILTER_ERR_NO_COL_FOUND, 
	SDB_FILTER_ERR_INVALID_RANGE_VALUE_DOTS, SDB_FILTER_ERR_INVALID_TYPE_NULL }