<?php
/*
 +-------------------------------------------------------------------------+
 | Copyright (C) 2004-2025 The Cacti Group                                 |
 |                                                                         |
 | This program 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.                  |
 |                                                                         |
 | This program 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.                            |
 +-------------------------------------------------------------------------+
 | Cacti: The Complete RRDtool-based Graphing Solution                     |
 +-------------------------------------------------------------------------+
 | This code is designed, written, and maintained by the Cacti Group. See  |
 | about.php and/or the AUTHORS file for specific developer information.   |
 +-------------------------------------------------------------------------+
 | http://www.cacti.net/                                                   |
 +-------------------------------------------------------------------------+
*/

/**
 * db_fetch_cell - run a 'select' sql query and return the first column of the
 *   first row found
 *
 * @param  (string)        The SQL query to execute
 * @param  (string)        Use this column name instead of the first one
 * @param  (int)           The number of parallel threads to use
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param mixed $sql
 * @param mixed $col_name
 * @param mixed $threads
 * @param mixed $log
 * @param mixed $db_conn
 *
 * @return (bool)  The output of the sql query as a single variable
 */
function db_fetch_cell_parallel($sql, $col_name = '', $threads = 2, $log = true, $db_conn = false) {
	global $config;

	if (!empty($config['DEBUG_SQL_FLOW'])) {
		db_echo_sql('db_fetch_cell($sql, $col_name = \'' . $col_name . '\', $log = true, $db_conn = false)' . "\n");
	}

	return db_fetch_cell_prepared($sql, [], $col_name, $log, $db_conn);
}

/**
 * db_fetch_cell_prepared - run a 'select' sql query and return the first column of the
 *   first row found
 *
 * @param  (string)        The SQL query to execute
 * @param  (array)         An array of values to be prepared into the SQL
 * @param  (string)        Use this column name instead of the first one
 * @param  (int)           The number of parallel threads to use
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param mixed $sql
 * @param mixed $params
 * @param mixed $col_name
 * @param mixed $threads
 * @param mixed $log
 * @param mixed $db_conn
 *
 * @return (bool) The output of the sql query as a single variable
 */
function db_fetch_cell_parallel_prepared($sql, $params = [], $col_name = '', $threads = 2, $log = true, $db_conn = false) {
	global $config;

	if (!empty($config['DEBUG_SQL_FLOW'])) {
		db_echo_sql('db_fetch_cell_prepared($sql, $params = ' . clean_up_lines(var_export($params, true)) . ', $col_name = \'' . $col_name . '\', $log = true, $db_conn = false)' . "\n");
	}

	return db_execute_prepared($sql, $params, $log, $db_conn, 'Cell', false, 'db_fetch_cell_return', $col_name);
}

/**
 * db_fetch_row - run a 'select' sql query and return the first row found
 *
 * @param  (string)        The SQL query to execute
 * @param  (int)           The number of parallel threads to use
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param mixed $sql
 * @param mixed $threads
 * @param mixed $log
 * @param mixed $db_conn
 *
 * @return (bool|array) The first row of the result or false if failed
 */
function db_fetch_row_parallel($sql, $threads = 2, $log = true, $db_conn = false) {
	global $config;

	if (!empty($config['DEBUG_SQL_FLOW'])) {
		db_echo_sql('db_fetch_row(\'' . clean_up_lines($sql) . '\', $log = ' . $log . ', $db_conn = ' . ($db_conn ? 'true' : 'false') .')' . "\n");
	}

	return db_fetch_row_prepared($sql, [], $log, $db_conn);
}

/**
 * db_fetch_row_prepared - run a 'select' sql query and return the first row found
 *
 * @param  (string)        The SQL query to execute
 * @param  (array)         An array of values to be prepared into the SQL
 * @param  (int)           The number of parallel threads to use
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param mixed $sql
 * @param mixed $params
 * @param mixed $threads
 * @param mixed $log
 * @param mixed $db_conn
 *
 * @return (bool|array) The first row of the result or false if failed
 */
function db_fetch_row_parallel_prepared($sql, $params = [], $threads = 2, $log = true, $db_conn = false) {
	global $config;

	if (!empty($config['DEBUG_SQL_FLOW'])) {
		db_echo_sql('db_fetch_row_prepared(\'' . clean_up_lines($sql) . '\', $params = (\'' . implode('\', \'', $params) . '\'), $log = ' . $log . ', $db_conn = ' . ($db_conn ? 'true' : 'false') .')' . "\n");
	}

	return db_execute_prepared($sql, $params, $log, $db_conn, 'Row', false, 'db_fetch_row_return');
}

/**
 * db_fetch_assoc - run a 'select' sql query and return all rows found
 *
 * @param  (string)        The SQL query to execute
 * @param  (int)           The number of parallel threads to use
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param mixed $sql
 * @param mixed $threads
 * @param mixed $log
 * @param mixed $db_conn
 *
 * @return (bool|array)    The entire result set or false on error
 */
function db_fetch_assoc_parallel($sql, $threads = 2, $log = true, $db_conn = false) {
	global $config;

	if (!empty($config['DEBUG_SQL_FLOW'])) {
		db_echo_sql('db_fetch_assoc($sql, $log = true, $db_conn = false)' . "\n");
	}

	return db_fetch_assoc_prepared($sql, [], $log, $db_conn);
}

/**
 * db_fetch_assoc_prepared - run a 'select' sql query and return all rows found
 *
 * @param  (string)        The sql query to execute
 * @param  (array)         An array of values to be prepared into the SQL
 * @param  (int)           The number of parallel threads to use
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param mixed $sql
 * @param mixed $params
 * @param mixed $threads
 * @param mixed $log
 * @param mixed $db_conn
 *
 * @return (bool|array)    The entire result or false on error
 */
function db_fetch_assoc_parallel_prepared($sql, $params = [], $threads = 2, $log = true, $db_conn = false) {
	global $config;

	if (!empty($config['DEBUG_SQL_FLOW'])) {
		db_echo_sql('db_fetch_assoc_prepared($sql, $params = array(), $log = true, $db_conn = false)' . "\n");
	}

	return db_execute_prepared($sql, $params, $log, $db_conn, 'Row', [], 'db_fetch_assoc_return');
}

/**
 * db_query_parallelize - break a query into pieces to parallelize and return
 *   components to the parent.  It will only work with very simple level of
 *   parallelism around both UNION and PARTITION queries including only
 *   certain aggregation functions.
 *
 * Cases that are not currently supported are:
 * - Queries with aggregation other than MIN, MAX, AVG, SUM, and COUNT
 *
 * More specifically, it will not handle the following aggregators:
 * - BIT_OR, BIT_AND, or BIT_XOR
 * - GROUP_CONCAT
 * - COUNT DISTINCT
 * - JSON*
 * - STD, STDDEV, STDDEV_POP, STDDEV_SAMP
 * - VARIANCE, VAR_POP, VAR_SAMP
 *
 * At some point, additional support may be added, but at a later date
 *
 * The components that are returned are as follows:
 *
 * $response = array(
 *    'error'              => boolean true or false
 *    'error_message'      => a message to the caller about the error
 *    'temp_table_name'    => string of the temp table to be removed after query complete
 *    'temp_create_syntax' => '',
 *    'reduce_query'       => string of the final reduce query
 *    'map_queries'        => an array of map queries decomposed from the original query
 * );
 *
 * @params (string)        The query to execute
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param mixed $sql
 * @param mixed $log
 * @param mixed $db_conn
 * @params (bool)          If set to true, the temporary table will be created
 *
 * @return (array)         The query pieces, the map and reduce components with
 *                         and error and error_message
 */
function db_query_parallelize($sql, $log = true, $db_conn = false) {
	global $debug;

	/**
	 * For simplicity, In our tokenization of the query, we will ignore
	 * any query that includes these custom keyword options.
	 *
	 */
	$ignores = [
		'DISTINCTROW',
		'HIGH_PRIORITY',
		'SQL_SMALL_RESULT',
		'SQL_BIG_RESULT',
		'SQL_BUFFER_RESULT',
		'SQL_CACHE',
		'SQL_NO_CACHE',
		'SQL_CALC_FOUND_ROWS',
		'FOR UPDATE',
		'LOCK IN SHARE MODE'
	];

	$replaces = ['', '', '', '', '', '', '', '', ''];

	$sql = str_replace($ignores, $replaces, $sql);

	while (substr_count($sql, '  ')) {
		$sql = str_replace('  ', ' ', $sql);
	}

	/**
	 * Additionally, we run a quick and dirty Query cleanup
	 * to remove some extra spaces, line breaks and tabs that
	 * may compromise the further tokenization from
	 * being handled properly.
	 *
	 * I know this will break some exotic queries,
	 * and should in general be avoided, but I'm writing
	 * this and not you ;)
	 *
	 * This cleanup has two phases.
	 * - Remove some odd formatting, line breaks and tabs
	 * - Remove extra spaces
	 */
	$sql = str_replace(
		["\n", "\t", "\r", ';'],
		[' ', ' ', ' ', ''],
		$sql
	);

	while (substr_count($sql, '  ')) {
		$sql = str_replace('  ', ' ', $sql);
	}

	if ($debug) {
		print PHP_EOL . 'Pre-processed Query: ' . $sql . PHP_EOL . PHP_EOL;
	}

	/**
	 * In this prototype, we will reject a number of allowed SQL forms
	 * to simply keep it simple for the moment.  In the future, we may support
	 * more elaborate forms of SQL based upon a valid feature request.
	 */
	$disallowed =
		// Import and export
		'/(INFILE '             . // Import Option
		'|OUTFILE '            . // Export Option
		'|INTO DUMPFILE '      . // Export Option
		'|INTO '               . // Export Option

		// Aggregation Functions
		'|VARIANCE'            . // Aggregation Function
		'|VAR_'                . // Aggregation Function
		'|STD'                 . // Aggregation Function
		'|STDDEV'              . // Aggregation Function
		'|COUNT\(DISTINCT'     . // Aggregation Function

		// Oddball Query Options
		'|LOCK IN SHARE MODE ' . // Query Option
		'|FOR UPDATE '         . // Update Option
		'|FOR SYSTEM TIME '    . // System Table Support
		'|PROCEDURE '          . // Stored Procedures
		'|PARTITION '          . // PARTITION specific query

		// JSON Functions
		'|JSON_'               . // Any JSON Functions

		// Index Options
		'|FORCE '              . // FORCE Index
		'|IGNORE '             . // IGNORE Index
		'| USE )/i';             // USE Index

	/**
	 * Track UNION queries as these queries must be handled using
	 * extra logic to properly move the WHERE clause into the
	 * map queries to improve performance of those map queries.
	 */
	$is_union = false;

	/**
	 * Our response object which can then be used by the caller
	 * to create the table, perform queries, etc.
	 */
	$response = [
		'error'              => false,
		'error_message'      => '',
		'temp_table_name'    => 'par_' . generate_hash() . '_res', // Limited to 40 characters due to process table length limit
		'temp_create_syntax' => '',
		'reduce_query'       => '',
		'map_queries'        => []
	];

	// Check for disallowed tokens
	if (preg_match($disallowed, $sql)) {
		$response['error']         = true;
		$response['error_message'] = 'The SQL Query included one of the disallowed SQL options, functions, or modifiers.';

		return $response;
	}

	// Create a split on the FROM clause
	$from_split   = explode('FROM ', $sql, 2);

	/**
	 * We need to track the partitions for operating on partitioned
	 * tables for the MAP phase.
	 */
	$partitions = [];

	/**
	 * Perform some pre-checks, UNION queries, by their nature are parallelizable.
	 * However, queries without UNIONS must come from a partitioned tables.
	 */
	if (stripos($sql, 'UNION') === false && stripos($sql, 'UNION ALL') === false) {
		// Find the table name if not a union query
		if (count($from_split) > 1) {
			$bits       = explode(' ', trim($from_split[1]));
			$table_name = trim($bits[0], '`');

			if (!db_table_partitioned($table_name, $log, $db_conn, $partitions)) {
				$response['error']         = true;
				$response['error_message'] = 'The SQL query main table is not partitioned.';

				return $response;
			}
		} else {
			$response['error']         = true;
			$response['error_message'] = 'The SQL query lacks a FROM clause.';

			return $response;
		}
	} else {
		$is_union     = true;
		$is_union_all = (stripos($sql, 'UNION ALL') !== false) ? true : false;

		/**
		 * Here we check if the UNION is properly nested where the
		 * GROUP BY, HAVING, LIMIT, etc. are processed inside or outside
		 * the table select/from logic. If outside or simple, then we need
		 * to skip the checks below GROUP BY, LIMIT, ORDER, etc. below as
		 * they are each contained within the UNION's themselves.
		 */
		if (str_starts_with(trim($from_split[1]), '(')) {
			$simple_union = false;
		} else {
			$simple_union = true;
		}
	}

	/**
	 * Query clauses must come in a specific order.  That order is always the same, though
	 * with sub-queries, it can get a bit more complicated.  Here is the general
	 * order. We are not supporting Sub-Queries with the exception of UNION queries.
	 * So, we will make broad assumptions about the structure of the queries and
	 * reject them otherwise.
	 *
	 * SELECT
	 * FROM
	 * JOIN(S)
	 * WHERE
	 * GROUP BY [ WITH ROLLUP ]
	 * HAVING
	 * ORDER BY
	 * LIMIT
	 *
	 * Knowning this information, we can tokenize the SQL
	 * statement and prepare for parallelization.  The MySQL/MariaDB language
	 * syntax has a very many modifiers that we hope not to have to cover
	 * in this exercise.  They include, but are not limited to the items included
	 * in the following two links:
	 *
	 * Select Syntax:
	 * https://mariadb.com/kb/en/select/
	 *
	 * Table References and Join Options:
	 * https://mariadb.com/kb/en/join-syntax/
	 *
	 */

	$tokens = [
		'raw_select'   => '',
		'outer_select' => '',
		'inner_select' => '',
		'from'         => '',
		'where'        => '',
		'groupby'      => '',
		'having'       => '',
		'orderby'      => '',
		'limit'        => ''
	];

	/**
	 * Tokenizing the SQL query can be handled in many ways.  I'm taking the
	 * approach of handing UNION queries separately from PARTITIONED queries
	 * and I'll be working in reverse breaking the query into the various
	 * tokens.
	 *
	 * I know that this may be inneficient, but it the way I will handle
	 * for now.  At some point it can be improved/optimized.
	 */
	if (!$is_union || ($is_union && !$simple_union)) {
		$selectstr = $from_split[0];
		$tokenstr  = $from_split[1];

		if (stripos($tokenstr, 'LIMIT') !== false) {
			$tempstr = str_ireplace('limit', 'LIMIT', $tokenstr);
			$parts   = explode('LIMIT', $tempstr);

			$elements        = cacti_sizeof($parts);
			$tokens['limit'] = 'LIMIT ' . trim($parts[$elements - 1]);

			unset($parts[$elements - 1]);

			$tokenstr = trim(implode('LIMIT', $parts));
		}

		if (stripos($tokenstr, 'ORDER BY') !== false) {
			$tempstr = str_ireplace('order by', 'ORDER BY', $tokenstr);
			$parts   = explode('ORDER BY', $tempstr);

			$elements          = cacti_sizeof($parts);
			$tokens['orderby'] = 'ORDER BY ' . trim($parts[$elements - 1]);

			unset($parts[$elements - 1]);

			$tokenstr = trim(implode('ORDER BY', $parts));
		}

		if (stripos($tokenstr, 'HAVING') !== false) {
			$tempstr = str_ireplace('having', 'HAVING', $tokenstr);
			$parts   = explode('HAVING', $tempstr);

			$elements          = cacti_sizeof($parts);
			$tokens['having']  = 'HAVING ' . trim($parts[$elements - 1]);

			unset($parts[$elements - 1]);

			$tokenstr = trim(implode('HAVING', $parts));
		}

		if (stripos($tokenstr, 'GROUP BY') !== false) {
			$tempstr = str_ireplace('group by', 'GROUP BY', $tokenstr);
			$parts   = explode('GROUP BY', $tempstr);

			$elements          = cacti_sizeof($parts);
			$tokens['groupby'] = 'GROUP BY ' . trim($parts[$elements - 1]);

			unset($parts[$elements - 1]);

			$tokenstr = trim(implode('GROUP BY', $parts));
		}

		if (stripos($tokenstr, 'WHERE') !== false) {
			$tempstr = str_ireplace('where', 'WHERE', $tokenstr);
			$parts   = explode('WHERE', $tempstr);

			$elements          = cacti_sizeof($parts);
			$tokens['where']   = 'WHERE ' . trim($parts[$elements - 1]);

			unset($parts[$elements - 1]);

			$tokenstr = trim(implode('WHERE', $parts));
		}

		$tokens['from'] = 'FROM ' . trim($tokenstr);

		// Format the raw inner and outer selectors
		if (stripos($selectstr, 'SELECT') !== false) {
			$tempstr              = str_ireplace('select', '', $selectstr);
			$tokens['raw_select'] = trim($tempstr);
		}

		// Modify the outer selector to remove aliases
		$outer = explode(',', $tokens['raw_select']);
		$inner = '';

		foreach ($outer as $index => $o) {
			$aggregate = db_get_aggregate($o);

			if ($aggregate != '' && $debug) {
				print 'Aggregate: ' . $aggregate . PHP_EOL;
			}

			if ($aggregate == '') {
				$parts = explode('.', $o);

				if (cacti_sizeof($parts) > 1) {
					if (stripos($parts[1], 'AS') !== false) {
						$parts[1]      = str_ireplace('as', 'AS', $parts[1]);
						$bits          = explode('AS', $parts[1]);
						$outer[$index] = trim($bits[1]);
					} else {
						$outer[$index] = trim($parts[1]);
					}
				} elseif (stripos($o, 'AS') !== false) {
					$o             = str_ireplace('as', 'AS', $o);
					$bits          = explode('AS', $o);
					$outer[$index] = trim($bits[1]);
				} else {
					$outer[$index] = trim($parts[0]);
				}
			} else {
				$alias = $index;

				if (stripos($o, 'AS') !== false) {
					$o     = str_ireplace('as', 'AS', $o);
					$bits  = explode('AS', $o);
					$alias = trim($bits[1]);
				}

				if ($aggregate == 'AVG') {
					// Handle AVG Aggregator
				} elseif ($aggregate == 'COUNT') {
					// Handle COUNT Aggregator
				} else {
					// Handle Aggregators MIN, MAX, SUM
				}
			}

			$inner .= ($inner != '' ? ', ':'') . $outer[$index];
		}

		// Handle UNIONS into Partitions

		// Determine CREATE Table Syntax from UNION or PARTITION columns

		$tokens['outer_select'] = implode(',', $outer);
	} else {
		$sql = str_ireplace(['union all', 'union'], ['UNION ALL', 'UNION'], $sql);

		if ($is_union_all) {
			$partitions = explode('UNION ALL', $sql);
		} else {
			$partitions = explode('UNION', $sql);
		}

		if (cacti_sizeof($partitions)) {
			foreach ($partitions as $index => $p) {
				$partitions[$index] = trim($p);
			}
		}

		$tokens['raw_select']   = '*';
		$tokens['outer_select'] = '*';

		// Determine CREATE Table Syntax from Partition logic
	}

	if ($debug) {
		print 'SQL Tokens:';
		print PHP_EOL;
		print_r($tokens);

		print PHP_EOL;
		print 'Partitions:';
		print PHP_EOL;
		print_r($partitions);
	}

	return ['tokens' => $tokens, 'partitions' => $partitions];
}

/**
 * db_get_aggregate - Return the aggregate function from a SQL select item
 *
 * @param  (string)        The SQL column string
 * @param mixed $colsql
 *
 * @return (string)        The column aggregate function
 */
function db_get_aggregate($colsql) {
	// Decompose and prepare the query for parallelization
	$aggregators = [
		'SUM(',
		'COUNT(',
		'MIN(',
		'MAX(',
		'AVG('
	];

	foreach ($aggregators as $a) {
		if (stripos($colsql, $a) !== false) {
			return trim($a, '(');
		}
	}

	return '';
}

/**
 * db_table_partitioned - checks whether a table is partitioned and returns partition numbers
 *
 * @param  (string)        The name of the table
 * @param  (bool)          Whether to log error messages, defaults to true
 * @param  (bool|resource) The connection to use or false to use the default
 * @param  (array)         The connection to use or false to use the default
 * @param mixed $table
 * @param mixed $log
 * @param mixed $db_conn
 * @param mixed $partitions
 *
 * @return (bool) The output of the sql query as a single variable
 */
function db_table_partitioned($table, $log = true, $db_conn = false, &$partitions = []) {
	static $results;

	if (isset($results[$table]) && !defined('IN_CACTI_INSTALL') && !defined('IN_PLUGIN_INSTALL')) {
		$partitions = $results[$table]['partitions'];

		return $results[$table]['result'];
	}

	// Separate the database from the table and remove backticks
	preg_match("/([`]{0,1}(?<database>[\w_]+)[`]{0,1}\.){0,1}[`]{0,1}(?<table>[\w_]+)[`]{0,1}/", $table, $matches);

	if ($matches !== false && db_table_exists($table, false, $db_conn) && array_key_exists('table', $matches)) {
		$sql = 'SHOW CREATE TABLE ' . $matches['table'];

		$create = db_fetch_row($sql, $log, $db_conn);

		if (cacti_sizeof($create)) {
			$create = $create['Create Table'];
		}

		if (stripos($create, 'PARTITION ') !== false) {
			$results[$table]['result'] = true;

			$parts = explode("\n", $create);

			foreach ($parts as $l) {
				if (stripos($l, 'PARTITION ') !== false && stripos($l, 'PARTITION BY') === false) {
					$comp         = explode(' ', trim($l));
					$partitions[] = trim($comp[1], '`');
				}
			}

			$results[$table]['partitions'] = $partitions;
		} else {
			$results[$table]['partitions'] = [];
			$results[$table]['result']     = false;
		}

		return $results[$table]['result'];
	}

	return false;
}
