<?php
# MantisBT - A PHP based bugtracking system

# MantisBT is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# MantisBT is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with MantisBT.  If not, see <http://www.gnu.org/licenses/>.

/**
 * DbQuery class.
 * @copyright Copyright 2017 MantisBT Team - mantisbt-dev@lists.sourceforge.net
 * @link http://www.mantisbt.org
 * @package MantisBT
 * @subpackage classes
 *
 * @uses config_api.php
 * @uses constant_inc.php
 * @uses database_api.php
 * @uses logging_api.php
 */

require_api( 'config_api.php' );
require_api( 'constant_inc.php' );
require_api( 'database_api.php' );
require_api( 'logging_api.php' );


/**
 * Database Query abstraction class.
 *
 * Allows building a query and fetching data, providing compatible functionality
 * for all supported databases. Hides the underlaying details from the ADOdb layer.
 *
 * Supports inline parameters with ":label" syntax, and anonymous parameters
 * through the param() method.
 * Supports binding arrays which will be later expanded to individual parameters.
 *
 * The internal processing steps for query string are:
 * 1) $query_string: stores the user input string, containing parameter tokens for all
 *   supported formats:
 *     - ":{string}" For labeled parameters. Bound values are stored in $query_bind_array
 *     - "${int}" For anonymous parameters. Bound values are stored in $query_autobind_array
 *     - "${string}{int}" For special constructs, eg: $in0 for late binding IN clauses
 * 2) $expanded_query_string: stores the query string after expansion of special constructs
 *   into standard "${int}" parameters
 * 3) $db_query_string: stores the query string after all parameters have been renamed,
 *   reordered and converted to the specific database parameter syntax expected by ADOdb.
 *   Binded values are stored in $db_param_array
 *
 * The steps for query string processing are performed by:
 * - process_expand_params(): converts query string (1) into (2)
 * - process_bind_params(): converts query string(2) into (3)
 * - process_sql_syntax(): modifies query string (3) performing some general and database
 *   specific modifications.
 *
 * Execution of the query is supported by:
 * - db_execute(): performs the low level, actual execution from ADOdb
 * - execute(): performs all query processing steps and then calls db_execute().
 */

class DbQuery {

	/**
	 * Stores the user input for query string.
	 * @var string
	 */
	protected $query_string;

	/**
	 * Stores the query string with "late binding" placeholders expanded into final
	 * sql syntax and parameters.
	 * @var string
	 */
	protected $expanded_query_string = null;

	/**
	 * Array of values binded to labeled parameters, indexed by label names.
	 * @var array
	 */
	protected $query_bind_array;

	/**
	 * Array of values binded to anonymous parameters, indexed by numerical index.
	 * @var array
	 */
	protected $query_autobind_array = array();

	/**
	 * Counter for assigning numerical indexes to anonymous parameters.
	 * @var integer
	 */
	protected $query_autobind_count = 0;

	/**
	 * Stores the final query string to be passed to ADOdb.
	 * @var string
	 */
	protected $db_query_string;

	/**
	 * Array of values for parameters included in the final query string for ADOdb,
	 * indexed as needed for the ADOdb driver
	 * @var array
	 */
	protected $db_param_array;

	/**
	 * Stores the adodb result set for the query after it has been executed, or false if the query failed.
	 * @var  IteratorAggregate|boolean
	 */
	protected $db_result = null;

	/**
	 * Stores execution time of the query (microseconds).
	 * @var integer
	 */
	protected $db_query_time = null;

	/**
	 * Stores the latest fetched array fromthe result set.
	 * @var array
	 */
	protected $current_row = null;

	/**
	 * Stores row limit value for query execution.
	 * Number of rows to be returned from the query result
	 * Use -1 to disable this option.
	 * @var integer
	 */
	protected $select_limit = -1;

	/**
	 * Stores the offset value for query execution.
	 * The resultset starts at this position from the query result
	 * Use -1 to disable this option.
	 * @var integer
	 */
	protected $select_offset = -1;

	/**
	 * Array to manage late binding for IN constructs
	 * @var array
	 */
	protected $late_binding_in_clause = array();

	protected static $oracle_in_limit = 1000; # this could be a constant


	/**
	 * Construct a new query object.
	 *
	 * Optional parameters are the query string, and an array of values to be
	 * bound to labeled parameters.
	 *
	 * @param string $p_query_string Query string
	 * @param array $p_bind_array    Bind values
	 *
	 * @return void
	 */
	public function __construct( string $p_query_string = '', array $p_bind_array = [] ) {
		$this->query_string = $p_query_string;
		$this->query_bind_array = $p_bind_array;
	}

	/**
	 * Set or replaces current query string
	 * @param string $p_query_string	Query string
	 * @return void
	 */
	public function sql( $p_query_string ) {
		$this->query_string = $p_query_string;
	}

	/**
	 * Append to current query string
	 * @param string $p_query_string	Query string
	 * @return void
	 */
	public function append_sql( $p_query_string ) {
		$this->query_string .= $p_query_string;
	}

	/**
	 * Creates a string containing a parameter that can be appended to the query string
	 * The provided value is binded to the parameter and stored for use at execution time
	 * The parameters created by this method are anonymous parameters, so they can't be
	 * accessed later to have values modified or rebinded.
	 * The format of the token created is: "$n", where "n" is an incremental integer
	 * @param mixed $p_value	Value to bind for this parameter
	 * @return string			Token string
	 */
	public function param( $p_value ) {
		$t_new_id = $this->query_autobind_count++;
		$this->query_autobind_array[$t_new_id] = $p_value;
		$t_par = '$' . $t_new_id;
		return $t_par;
	}

	/**
	 * Sets the "limit" value. This value is used for all subsequent query executions
	 * Use -1 to disable this option
	 * @param integer $p_limit	Number of rows to limit
	 * @return void
	 */
	public function set_limit( $p_limit = -1 ) {
		$this->select_limit = $p_limit;
	}

	/**
	 * Sets the "offset" value. This value is used for all subsequent query executions
	 * Use -1 to disable this option
	 * @param integer $p_offset	Number of rows to offset
	 * @return void
	 */
	public function set_offset( $p_offset = -1 ) {
		$this->select_offset = $p_offset;
	}

	/**
	 * Executes the query, performing all preprocess and binding steps.
	 *
	 * A bind array can be provided, which will be added to current bindings.
	 * If specified, limit and offset options will affect this execution only.
	 *
	 * @param array $p_bind_array Array for binding values
	 * @param int   $p_limit      Limit value
	 * @param int   $p_offset     Offset value
	 *
	 * @return IteratorAggregate|boolean ADOdb result set or false if the query failed.
	 */
	public function execute( array $p_bind_array = [], $p_limit = null, $p_offset = null ) {
		# For backwards compatibility with legacy code still relying on DB API,
		# we need to save the parameters count before binding otherwise it will
		# be reset after query execution, which will cause issues on RDBMS with
		# numbered params (e.g. PostgreSQL).
		db_param_push();

		# bind values if provided
		if( !empty( $p_bind_array ) ) {
			$this->bind_values( $p_bind_array );
		}

		# preprocess parameters
		$this->process_expand_params();
		$this->process_bind_params();
		$this->process_sql_syntax();

		$t_result = $this->db_execute($p_limit, $p_offset);
		db_param_pop();
		return $t_result;
	}

	/**
	 * Call ADOdb execution of sql string.
	 * At this point all preprocessing and value binding has been performed.
	 * @param integer $p_limit	Limit value
	 * @param integer $p_offset	Offset value
	 * @return IteratorAggregate|boolean ADOdb result set or false if the query failed.
	 */
	protected function db_execute( $p_limit = null, $p_offset = null ) {
		global $g_db;

		# get limit and offset
		if( null !== $p_limit ) {
			$t_limit = $p_limit;
		} else {
			$t_limit = $this->select_limit;
		}
		if( null !== $p_offset ) {
			$t_offset = $p_offset;
		} else {
			$t_offset = $this->select_offset;
		}

		$t_start = microtime( true );

		if( ( $t_limit != -1 ) || ( $t_offset != -1 ) ) {
			$this->db_result = $g_db->SelectLimit( $this->db_query_string, $t_limit, $t_offset, $this->db_param_array );
		} else {
			$this->db_result = $g_db->Execute( $this->db_query_string, $this->db_param_array );
		}

		$this->db_query_time = number_format( microtime( true ) - $t_start, 4 );

		$this->log_query();

		if( !$this->db_result ) {
			db_error( $this->db_query_string );
			trigger_error( ERROR_DB_QUERY_FAILED, ERROR );
			$this->db_result = false;
		}
		$this->current_row = null;
		return $this->db_result;
	}

	/**
	 * Logs data from latest execution
	 * @return void
	 */
	protected function log_query() {
		global $g_db_log_queries, $g_queries_array;
		if( ON == $g_db_log_queries ) {
			$t_query_text = db_format_query_log_msg( $this->db_query_string, $this->db_param_array );
			log_event( LOG_DATABASE, array( $t_query_text, $this->db_query_time ) );
		} else {
			# If not logging the queries the actual text is not needed
			$t_query_text = '';
		}
		array_push( $g_queries_array, array( $t_query_text, $this->db_query_time ) );
	}

	/**
	 * Bind values for labeled parameters.
	 * An array indexed by label names must be provided.
	 * @param array $p_values_array	Array of values
	 * @return void
	 */
	public function bind_values( array $p_values_array ) {
		$this->query_bind_array = $p_values_array + $this->query_bind_array;
	}

	/**
	 * Binds values to a labeled parameters
	 * When using:
	 * bind(string, value) binds one value to one parameter
	 * bind(array) is an alias for method "bind_values(array)"
	 * @param mixed $p_label_or_values	A labeled parameter name, or array for value bindings
	 * @param mixed $p_value			A value to bind
	 * @return void
	 */
	public function bind( $p_label_or_values, $p_value = null ) {
		if( is_array( $p_label_or_values ) ) {
			# is a values array
			$this->bind_values( $p_label_or_values );
		} else {
			# is a label string
			$this->query_bind_array[$p_label_or_values] = $p_value;
		}
	}

	/**
	 * Performs some specific preprocessing on sql string
	 * - replace table names from abbreviated syntax
	 * - replaces boolean values to fix some db driver inconsistencies
	 * - process oracle syntax fixes for compatibility
	 * @return void
	 */
	protected function process_sql_syntax() {
		global $g_db;

		static $s_prefix;
		static $s_suffix;
		if( $s_prefix === null ) {
			# Determine table prefix and suffixes including trailing and leading '_'
			$s_prefix = trim( config_get_global( 'db_table_prefix' ) );
			$s_suffix = trim( config_get_global( 'db_table_suffix' ) );

			if( !empty( $s_prefix ) && '_' != substr( $s_prefix, -1 ) ) {
				$s_prefix .= '_';
			}
			if( !empty( $s_suffix ) && '_' != substr( $s_suffix, 0, 1 ) ) {
				$s_suffix = '_' . $s_suffix;
			}
		}

		$this->db_query_string = strtr( $this->db_query_string,
			array( '{' => $s_prefix, '}' => $s_suffix )
			);

		# check parameters for special treatment of boolean types
		# use the native values provided by the ADOdb driver
		foreach( $this->db_param_array as $t_key => $t_value ) {
			if( $t_value === false ) {
				$this->db_param_array[$t_key] = $g_db->false;
			} elseif( $t_value === true ) {
				$this->db_param_array[$t_key] = $g_db->true;
			}
		}

		if( db_is_oracle() ) {
			$this->process_sql_syntax_oracle();
		}
	}

	/**
	 * Process current query string converting all internal parameter placeholders
	 * to final ADOdb parameter syntax.
	 * Will convert all labeled ":xxx", and anonymous "$n" parameters, and build
	 * a values array suitable for ADOdb.
	 * @param integer $p_counter_start
	 * @return integer	Number of parameters created
	 */
	protected function process_bind_params( $p_counter_start = 0) {
		global $g_db;

		# shortcut, if no values are binded, skip parameter replacement
		if( empty( $this->query_autobind_array ) && empty( $this->query_bind_array ) ) {
			$this->db_query_string = $this->query_string;
			$this->db_param_array = array();
		}

		$t_query_string = $this->expanded_query_string;

		$t_new_query = '';
		$t_new_binds = array();
		$t_par_index = $p_counter_start;
		$t_par_count = 0;
		$t_parts = preg_split( '/(:[a-z0-9_]+)|(\$[0-9]+)/mi', $t_query_string, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE );
		foreach( $t_parts as $t_part ) {

			$t_first = substr( $t_part, 0, 1 );

			if( $t_first === '$' || $t_first === ':' ) {

				$t_label = substr( $t_part, 1 );
				switch( $t_first ) {
					case '$':
						$t_value = $this->query_autobind_array[(int)$t_label];
						break;
					case ':':
						$t_value = $this->query_bind_array[$t_label];
						break;
				}

				if( is_array( $t_value ) ) {
					$t_params_for_array = array();
					foreach( $t_value as $t_array_item ) {
						$t_params_for_array[] = $g_db->Param( $t_par_index );
						$t_new_binds[$t_par_index] = $t_array_item;
						$t_par_count++;
						$t_par_index++;
					}
					$t_new_query .= '(' . implode( ',', $t_params_for_array ) . ')';
				} elseif( $t_value instanceof DbQuery ) {
					# preprocess subquery object
					$t_value->process_expand_params();
					$t_sub_params = $t_value->process_bind_params( $t_par_index );
					$t_par_index += $t_sub_params;
					$t_par_count += $t_sub_params;
					# append subquery
					$t_new_binds = $t_new_binds + $t_value->db_param_array;
					$t_new_query .= '(' . $t_value->db_query_string . ')';
				} else {
					$t_new_query .= $g_db->Param( $t_par_index );
					$t_new_binds[$t_par_index] = $t_value;
					$t_par_count++;
					$t_par_index++;
				}

				continue;
			}

			# default
			$t_new_query .= $t_part;
		}

		$this->db_query_string = $t_new_query;
		$this->db_param_array = $t_new_binds;

		return $t_par_count;
	}

	/**
	 * Builds an alternative IN expression to work around Oracle limits.
	 * (X, field) IN ( (X,V1), (X,V2), ... ) where X is a constant value
	 * is equivalent to: field IN (V1,V2,..)
	 * @param string $p_alias
	 * @param array $p_values
	 * @return string
	 */
	protected function helper_in_oracle_fix( $p_alias, array $p_values ) {
		$t_in_tuples = array();
		foreach( $p_values as $t_value ) {
			$t_in_tuples[] = '(1,' . $this->param( $t_value ) . ')';
		}
		$t_sql = '(1,' . $p_alias . ') IN (' . implode( ',', $t_in_tuples ) . ')';
		return $t_sql;
	}

	/**
	 * Process query string to expand late binding constructs
	 * @return void
	 */
	protected function process_expand_params() {
		# original query_string should not be modified to allow for rebinding
		$this->expanded_query_string = $this->query_string;

		if( !empty( $this->late_binding_in_clause ) ) {
			$this->process_expand_params_in();
		}
	}

	/**
	 * Process query string to expand late binding constructs for IN clauses
	 * @return void
	 */
	protected function process_expand_params_in() {
		$t_new_query = '';
		$t_parts = preg_split( '/(\$in[0-9]+)/m', $this->expanded_query_string, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE );
		foreach( $t_parts as $t_part ) {
			$t_is_token = substr( $t_part, 0, 3 ) === '$in';
			if( $t_is_token ) {
				$t_index = (int)substr( $t_part, 3 );
				$t_label =  $this->late_binding_in_clause[$t_index]['label'];
				$t_alias =  $this->late_binding_in_clause[$t_index]['alias'];
				$t_values = $this->query_bind_array[$t_label];
				if( count( $t_values ) > self::$oracle_in_limit ) {
					$t_new_query .= $this->helper_in_oracle_fix( $t_alias, $t_values );
				} elseif( count( $t_values ) == 1 ) {
					$t_new_query .= $t_alias . ' = ' . $this->param( reset( $t_values ) );
				} else {
					$t_new_query .= $t_alias . ' IN ' . $this->param( $t_values );
				}
				continue;
			}

			$t_new_query .= $t_part;
		}
		$this->expanded_query_string = $t_new_query;
	}

	/**
	 * Creates a string construction for an IN expression, providing:
	 * - alias: is the name of the column as a valid identifier in the final sql query
	 * - a label, or an array of values
	 * If a label is provided, the values must be binded as separate calls to binding methods.
	 *
	 * The returned string would replace the sql part for: "alias IN (x,x,x)"
	 *
	 * To work around Oracle limit of 1000 elements in IN clauses, using a label
	 * is recommended in situations that this number may be reached.
	 * This construct will then automatically deal with a compatible syntax fix.
	 * Using a label in this scenario is needed because the fix must be implemented
	 * with a late binding of the array at execution time (this also allows correctly
	 * rebinding the elements for the IN clause)
	 *
	 * @param string $p_alias			A valid sql column identifier
	 * @param mixed $p_label_or_values	Label or values array
	 * @return string	Constructed string to be added to query
	 */
	public function sql_in( $p_alias, $p_label_or_values ) {
		if( is_array( $p_label_or_values ) ) {
			if( db_is_oracle() && count( $p_label_or_values ) > self::$oracle_in_limit ) {
				$t_sql = $this->helper_in_oracle_fix( $p_alias, $p_label_or_values );
			} elseif( count( $p_label_or_values ) == 1 ) {
				$t_sql = $p_alias . ' = ' . $this->param( reset( $p_label_or_values ) );
			} else {
				$t_sql = $p_alias . ' IN ' . $this->param( $p_label_or_values );
			}
		} else {
			# is a label
			# create placeholder for late binding
			$t_new_index = count( $this->late_binding_in_clause );
			$this->late_binding_in_clause[$t_new_index] = array();
			$this->late_binding_in_clause[$t_new_index]['alias'] = $p_alias;
			$this->late_binding_in_clause[$t_new_index]['label'] = $p_label_or_values;
			$t_sql = '$in' . $t_new_index;
		}

		return $t_sql;
	}

	protected function process_sql_syntax_oracle() {
		# Remove "AS" keyword, because not supported with table aliasing
		# - Do not remove text literal within "'" quotes
		# - Will remove all "AS", except when it's part of a "CAST(x AS y)" expression
		#   To do so, we will assume that the "AS" following a "CAST", is safe to be kept.
		#   Using a counter for "CAST" appearances to allow nesting: CAST(CAST(x AS y) AS z)

		$t_query = $this->db_query_string;

		# split the string by the relevant delimiters. The delimiters will be part of the split array
		$t_parts = preg_split("/(')|( AS )|(CAST\s*\()/mi", $t_query, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
		$t_is_literal = false;
		$t_cast = 0;
		$t_new_query = '';
		foreach( $t_parts as $t_part ) {
			# if quotes, switch literal flag
			if( $t_part == '\'' ) {
				$t_is_literal = !$t_is_literal;
				$t_new_query .= $t_part;
				continue;
			}
			# if this part is litereal, do not change
			if( $t_is_literal ) {
				$t_new_query .= $t_part;
				continue;
			} else {
				# if there is "CAST" delimiter, flag the counter
				if( preg_match( '/^CAST\s*\($/i', $t_part ) ) {
					$t_cast++;
					$t_new_query .= $t_part;
					continue;
				}
				# if there is "AS"
				if( strcasecmp( $t_part, ' AS ' ) == 0 ) {
					# if there's a previous CAST, keep the AS
					if( $t_cast > 0 ) {
						$t_cast--;
						$t_new_query .= $t_part;
					} else {
						# otherwise, remove the " AS ", replace by a space
						$t_new_query .= ' ';
					}
					continue;
				}
				$t_new_query .= $t_part;
				continue;
			}
		}
		$t_query = $t_new_query;

		# Remove null bind variables in insert statements for default values support
		if( is_array( $this->db_param_array ) ) {
			preg_match( '/^[\s\n\r]*insert[\s\n\r]+(into){0,1}[\s\n\r]+(?P<table>[a-z0-9_]+)[\s\n\r]*\([\s\n\r]*[\s\n\r]*(?P<fields>[a-z0-9_,\s\n\r]+)[\s\n\r]*\)[\s\n\r]*values[\s\n\r]*\([\s\n\r]*(?P<values>[:a-z0-9_,\s\n\r]+)\)/i', $t_query, $t_matches );

			if( isset( $t_matches['values'] ) ) { #if statement is a INSERT INTO ... (...) VALUES(...)
				# iterates non-empty bind variables
				$i = 0;
				$t_fields_left = $t_matches['fields'];
				$t_values_left = $t_matches['values'];

				//for( $t_arr_index = 0; $t_arr_index < count( $this->db_param_array ); $t_arr_index++ ) {
				foreach( $this->db_param_array as $t_arr_index => $t_arr_value ) {
					# inserting fieldname search
					if( preg_match( '/^[\s\n\r]*([a-z0-9_]+)[\s\n\r]*,{0,1}([\d\D]*)\z/i', $t_fields_left, $t_fieldmatch ) ) {
						$t_fields_left = $t_fieldmatch[2];
						$t_fields_arr[$i] = $t_fieldmatch[1];
					}
					# inserting bindvar name search
					if( preg_match( '/^[\s\n\r]*(:[a-z0-9_]+)[\s\n\r]*,{0,1}([\d\D]*)\z/i', $t_values_left, $t_valuematch ) ) {
						$t_values_left = $t_valuematch[2];
						$t_values_arr[$i] = $t_valuematch[1];
					}
					# skip unsetting if bind array value not empty
					//if( $this->db_param_array[$t_arr_index] !== '' ) {
					if( $t_arr_value !== '' ) {
						$i++;
					} else {
						unset( $t_fields_arr[$i] );
						unset( $t_values_arr[$i] );
						unset( $this->db_param_array[$t_arr_index] );
						/*
						$t_arr_index--;
						# Shift array and unset bind array element
						for( $n = $i + 1; $n < count( $this->db_param_array ); $n++ ) {
							$this->db_param_array[$n-1] = $this->db_param_array[$n];
						}
						unset( $t_fields_arr[$i] );
						unset( $t_values_arr[$i] );
						unset( $this->db_param_array[count( $this->db_param_array ) - 1] );
						 */
					}
				}

				# Combine statement from arrays
				$t_query = 'INSERT INTO ' . $t_matches['table'] . ' (' . implode( ',', $t_fields_arr ) . ')'
					. ' VALUES (' . implode( ',', $t_values_arr ) . ')';
				/*
				$t_query = 'INSERT INTO ' . $t_matches['table'] . ' (' . $t_fields_arr[0];
				for( $i = 1; $i < count( $this->db_param_array ); $i++ ) {
					$t_query = $t_query . ', ' . $t_fields_arr[$i];
				}
				$t_query = $t_query . ') values (' . $t_values_arr[0];
				for( $i = 1; $i < count( $this->db_param_array ); $i++ ) {
					$t_query = $t_query . ', ' . $t_values_arr[$i];
				}
				$t_query = $t_query . ')';
				 */
			} else {
				# if input statement is NOT a INSERT INTO (...) VALUES(...)

				# "IS NULL" adoptation here
				$t_set_where_template_str = substr( md5( uniqid( rand(), true ) ), 0, 50 );
				$t_removed_set_where = '';

				# Find and remove temporarily "SET var1=:bind1, var2=:bind2 WHERE" part
				preg_match( '/^(?P<before_set_where>.*)(?P<set_where>[\s\n\r]*set[\s\n\r]+[\s\n\ra-z0-9_\.=,:\']+)(?P<after_set_where>where[\d\D]*)$/i', $t_query, $t_matches );
				$t_set_where_stmt = isset( $t_matches['after_set_where'] );

				if( $t_set_where_stmt ) {
					$t_removed_set_where = $t_matches['set_where'];
					# Now work with statement without "SET ... WHERE" part
					$t_templated_query = $t_matches['before_set_where'] . $t_set_where_template_str . $t_matches['after_set_where'];
				} else {
					$t_templated_query = $t_query;
				}

				# Replace "var1=''" by "var1 IS NULL"
				while( preg_match( '/^(?P<before_empty_literal>[\d\D]*[\s\n\r(]+([a-z0-9_]*[\s\n\r]*\.){0,1}[\s\n\r]*[a-z0-9_]+)[\s\n\r]*=[\s\n\r]*\'\'(?P<after_empty_literal>[\s\n\r]*[\d\D]*\z)/i', $t_templated_query, $t_matches ) > 0 ) {
					$t_templated_query = $t_matches['before_empty_literal'] . ' IS NULL ' . $t_matches['after_empty_literal'];
				}
				# Replace "var1!=''" and "var1<>''" by "var1 IS NOT NULL"
				while( preg_match( '/^(?P<before_empty_literal>[\d\D]*[\s\n\r(]+([a-z0-9_]*[\s\n\r]*\.){0,1}[\s\n\r]*[a-z0-9_]+)[\s\n\r]*(![\s\n\r]*=|<[\s\n\r]*>)[\s\n\r]*\'\'(?P<after_empty_literal>[\s\n\r]*[\d\D]*\z)/i', $t_templated_query, $t_matches ) > 0 ) {
					$t_templated_query = $t_matches['before_empty_literal'] . ' IS NOT NULL ' . $t_matches['after_empty_literal'];
				}

				$t_query = $t_templated_query;
				# Process input bind variable array to replace "WHERE fld=:12"
				# by "WHERE fld IS NULL" if :12 is empty
				while( preg_match( '/^(?P<before_var>[\d\D]*[\s\n\r(]+)(?P<var_name>([a-z0-9_]*[\s\n\r]*\.){0,1}[\s\n\r]*[a-z0-9_]+)(?P<dividers>[\s\n\r]*=[\s\n\r]*:)(?P<bind_name>[0-9]+)(?P<after_var>[\s\n\r]*[\d\D]*\z)/i', $t_templated_query, $t_matches ) > 0 ) {
					$t_bind_num = $t_matches['bind_name'];

					$t_search_substr = $t_matches['before_var'] . $t_matches['var_name'] . $t_matches['dividers'] . $t_matches['bind_name'] . $t_matches['after_var'];
					$t_replace_substr = $t_matches['before_var'] . $t_matches['var_name'] . '=:' . $t_matches['bind_name']. $t_matches['after_var'];

					if( $this->db_param_array[$t_bind_num] === '' ) {
						unset( $this->db_param_array[$t_bind_num] );
						/*
						for( $n = $t_bind_num + 1; $n < count( $this->db_param_array ); $n++ ) {
							$this->db_param_array[$n - 1] = $this->db_param_array[$n];
						}
						unset( $this->db_param_array[count( $this->db_param_array ) - 1] );
						 */
						$t_replace_substr = $t_matches['before_var'] . $t_matches['var_name'] . ' IS NULL ' . $t_matches['after_var'];
					}
					$t_query = str_replace( $t_search_substr, $t_replace_substr, $t_query );

					$t_templated_query = $t_matches['before_var'] . $t_matches['after_var'];
				}

				if( $t_set_where_stmt ) {
					# Put temporarily removed "SET ... WHERE" part back
					$t_query = str_replace( $t_set_where_template_str, $t_removed_set_where, $t_query );
					# Find and remove temporary "SET var1=:bind1, var2=:bind2 WHERE" part again
					preg_match( '/^(?P<before_set_where>.*)(?P<set_where>[\s\n\r]*set[\s\n\r]+[\s\n\ra-z0-9_\.=,:\']+)(?P<after_set_where>where[\d\D]*)$/i', $t_query, $t_matches );
					$t_removed_set_where = $t_matches['set_where'];
					$t_query = $t_matches['before_set_where'] . $t_set_where_template_str . $t_matches['after_set_where'];

					#Replace "SET fld1=:1" to "SET fld1=DEFAULT" if bind array value is empty
					$t_removed_set_where_parsing = $t_removed_set_where;

					while( preg_match( '/^(?P<before_var>[\d\D]*[\s\n\r,]+)(?P<var_name>([a-z0-9_]*[\s\n\r]*\.){0,1}[\s\n\r]*[a-z0-9_]+)(?P<dividers>[\s\n\r]*=[\s\n\r]*:)(?P<bind_name>[0-9]+)(?P<after_var>[,\s\n\r]*[\d\D]*\z)/i', $t_removed_set_where_parsing, $t_matches ) > 0 ) {
						$t_bind_num = $t_matches['bind_name'];
						$t_search_substr = $t_matches['before_var'] . $t_matches['var_name'] . $t_matches['dividers'] . $t_matches['bind_name'] ;
						$t_replace_substr = $t_matches['before_var'] . $t_matches['var_name'] . $t_matches['dividers'] . $t_matches['bind_name'] ;

						if( $this->db_param_array[$t_bind_num] === '' ) {
							unset( $this->db_param_array[$t_bind_num] );
							/*
							for( $n = $t_bind_num + 1; $n < count( $this->db_param_array ); $n++ ) {
								$this->db_param_array[$n - 1] = $this->db_param_array[$n];
							}
							unset( $this->db_param_array[count( $this->db_param_array ) - 1] );
							 */
							$t_replace_substr = $t_matches['before_var'] . $t_matches['var_name'] . '=DEFAULT ';
						}
						$t_removed_set_where = str_replace( $t_search_substr, $t_replace_substr, $t_removed_set_where );
						$t_removed_set_where_parsing = $t_matches['before_var'] . $t_matches['after_var'];
					}
					$t_query = str_replace( $t_set_where_template_str, $t_removed_set_where, $t_query );
				}
			}
		}

		$this->db_query_string = $t_query;
	}

	/**
	 * Compatibility method to support execution of legacy query syntax through db_query().
	 *
	 * @param string $p_query     Query string
	 * @param array  $p_params    Values array for parameters
	 * @param int    $p_limit     Query limit
	 * @param int    $p_offset    Query offset
	 * @param bool   $p_pop_param Set to false to leave the parameters on the stack
	 *
	 * @return IteratorAggregate|false ADOdb result set or false if the query failed
	 */
	public static function compat_db_query( string $p_query, array $p_params = [], int $p_limit = -1, int $p_offset = -1, bool $p_pop_param = true ) {
		global $g_db_param;

		$t_query = new DbQuery();
		$t_query->db_query_string = $p_query;
		$t_query->db_param_array = $p_params;

		$t_query->process_sql_syntax();

		# Pushing params to safeguard the ADOdb parameter count (required for pgsql)
		$g_db_param->push();

		$t_query->db_execute( $p_limit, $p_offset );

		# Restore ADOdb parameter count
		$g_db_param->pop();

		if( $p_pop_param && !empty( $p_params ) ) {
			$g_db_param->pop();
		}

		return $t_query->db_result;
	}

	/**
	 * Returns next row of values from current resultset, or false if empty or
	 * the pointer has reached the end.
	 * This method will execute current query if it hasn't been executed yet.
	 * @return array|boolean	Next row from result
	 */
	public function fetch() {
		if( null === $this->db_result ) {
			$this->execute();
		}
		if( !$this->db_result ) {
			return false;
		}
		$this->current_row = db_fetch_array( $this->db_result );
		return $this->current_row;
	}

	/**
	 * Returns all rows as an array
	 * @return array|boolean	Array with all rows from the result, false if result is empty.
	 */
	public function fetch_all() {
		if( null === $this->db_result ) {
			$this->execute();
		}
		if( !$this->db_result ) {
			return false;
		}
		$t_all_rows = array();
		while( $t_row = db_fetch_array( $this->db_result ) ) {
			$t_all_rows[] = $t_row;
		}
		return $t_all_rows;
	}

	/**
	 * Returns one value from current row from resultset
	 * Provided parameter can be a name of the column referenced in the query,
	 * or a numerical index (zero-based)
	 * Without column parameters,first column value will be returned.
	 *
	 * Current row is the latest one fetched, if none was fetched previously, an
	 * automatic fetch() is performed for first row
	 *
	 * @param integer|string $p_index_or_name	Column name or numeric index
	 * @return string|boolean	Value, or false if end of result or index is not valid
	 */
	public function value( $p_index_or_name = 0) {
		if( !$this->current_row ) {
			$this->fetch();
		}
		if( is_numeric( $p_index_or_name ) ) {
			if( $this->current_row && count( $this->current_row ) > $p_index_or_name ) {
				# get the element at that numerical position
				$t_keys = array_keys( $this->current_row );
				$t_value = $this->current_row[$t_keys[$p_index_or_name]];
			} else {
				$t_value = false;
			}
		} else {
			if( isset( $this->current_row[$p_index_or_name] ) ) {
				# get the value by column name
				$t_value = $this->current_row[$p_index_or_name];
			} else {
				$t_value = false;
			}
		}
		return $t_value;
	}

	/**
	 * Alias for value()
	 * @param integer|string $p_index_or_name	Column name or numeric index
	 */
	public function field( $p_index_or_name = 0) {
		return $this->value( $p_index_or_name );
	}

	/**
	 * Creates a string construction for a case-insensitive LIKE expression
	 * This is an alias for sql_like() with the force_ci parameter set to true.
	 * @param string $p_alias		A valid sql column identifier
	 * @param string $p_pattern		Pattern string
	 * @param string $p_escape		Escape character
	 * @return string	Constructed string to be added to query
	 */
	public function sql_ilike( $p_alias, $p_pattern, $p_escape = null ) {
		return $this->sql_like( $p_alias, $p_pattern, $p_escape, true );
	}

	/**
	 * Creates a string construction for a LIKE expression, providing:
	 * - alias: is the name of the column as a valid identifier in the final sql query
	 * - value: is the string used as pattern for the like expression.
	 * - escape: optionally, a character used as escape character in the pattern string
	 * Optionally, the expression can be forced to be case insensitive, otherwise the default
	 * behaviour from the database is used.
	 *
	 * The returned string would replace the sql part for: "alias LIKE 'xxx'"
	 *
	 * For portability reasons, the supported wildcards are '%' and '_'. Other special tokens
	 * are automatically escaped:
	 * - [] syntax in mssql are treated as literal characters.
	 * - \ as default escape char in mysql is treated as a literal character.
	 * Note that the pattern string uses c-style escaping, so a "\" character must be written as "\\"
	 *
	 * The pattern string must be prepared by the caller, with proper wildcards and character escaping.
	 *
	 * @param string $p_alias		A valid sql column identifier
	 * @param string $p_pattern		Pattern string
	 * @param string $p_escape		Escape character
	 * @param boolean $p_force_ci	If true, force a case-insensitive expression
	 * @return string	Constructed string to be added to query
	 */
	public function sql_like( $p_alias, $p_pattern, $p_escape = null, $p_force_ci = false ) {
		# for mssql replace "[" as this is a special non portable token
		if( db_is_mssql() && strpos( $p_pattern, '[' ) !== false ) {
			if( null === $p_escape = null ) {
				$p_escape = '\\';
			}
			$p_pattern = str_replace( '[', $p_escape . '[', $p_pattern );
		}

		# for mysql replace "\\" if this char is nor already a explicit escape char
		# because mysql uses \ as default escape char if ESCAPE caluse is not used
		if( db_is_mysql() && $p_escape != '\\' && strpos( $p_pattern, '\\' ) !== false ) {
			if( null === $p_escape = null ) {
					$p_escape = '\\';
			}
			$p_pattern = str_replace( '\\', $p_escape . '\\', $p_pattern );
		}

		$t_expr = $p_alias;
		$t_operator = 'LIKE';
		$t_pattern = $p_pattern;

		# Here we assume that by default:
		# mysql, mssql: have case-insensitive collations
		# pgsql, oracle: have case-sensitive collations
		# Otherwise, a more complicated discovery should be implemented.
		if( $p_force_ci ) {
			global $g_db_functional_type;
			switch( $g_db_functional_type ) {
				case DB_TYPE_PGSQL:
					$t_operator = 'ILIKE';
					break;
				case DB_TYPE_ORACLE:
					$t_expr = 'upper(' . $t_expr . ')';
					$t_pattern = strtoupper( $t_pattern );
					break;
			}
		}

		$t_sql = $t_expr . ' ' . $t_operator . ' ' . $this->param( $t_pattern );
		if( null !== $p_escape ) {
			$t_sql .= ' ESCAPE ' . $this->param( $p_escape );
		}
		return $t_sql;
	}
}
