rhymix/common/framework/parsers/dbquery/Query.php
2025-09-01 00:31:20 +09:00

795 lines
18 KiB
PHP

<?php
namespace Rhymix\Framework\Parsers\DBQuery;
/**
* Query class.
*/
class Query extends VariableBase
{
/**
* Attributes common to all queries.
*/
public $name;
public $type;
public $tables = array();
public $index_hints = array();
public $columns = array();
public $conditions = array();
public $groupby = null;
public $navigation = null;
public $select_distinct = false;
public $update_duplicate = false;
public $requires_pagination = false;
/**
* Attributes for subqueries in the <tables> or <columns> section.
*/
public $alias;
public $join_type;
public $join_conditions = array();
/**
* Attributes for subqueries in the <conditions> section.
*/
public $operation;
public $column;
public $pipe;
/**
* Attributes used during query string generation.
*/
protected $_prefix = '';
protected $_args = array();
protected $_column_list = array();
protected $_params = array();
/**
* Constants for alias handling.
*/
const ALIAS_NONE = 0;
const ALIAS_SPECIFIED = 1;
const ALIAS_ALWAYS = 2;
/**
* Generate the query string for this query.
*
* @param string $prefix
* @param array $args
* @param array $column_list
* @param int $count_only
* @return string
*/
public function getQueryString(string $prefix = '', array $args = [], array $column_list = [], int $count_only = 0): string
{
// Save the query information.
$this->_prefix = $prefix;
$this->_args = $args;
$this->_column_list = $column_list;
$this->_params = array();
// Call different internal methods depending on the query type.
switch ($this->type)
{
case 'SELECT':
$result = $this->_getSelectQueryString($count_only);
break;
case 'INSERT':
$result = $this->_getInsertQueryString();
break;
case 'UPDATE':
$result = $this->_getUpdateQueryString();
break;
case 'DELETE':
$result = $this->_getDeleteQueryString();
break;
default:
$result = '';
}
// Reset state and return the result.
$this->_prefix = '';
$this->_args = array();
$this->_column_list = array();
return $result;
}
/**
* Get the query parameters to use with the query string generated above.
*
* @return array
*/
public function getQueryParams(): array
{
return $this->_params;
}
/**
* Check if this query requires pagination.
*
* @return bool
*/
public function requiresPagination(): bool
{
return $this->requires_pagination;
}
/**
* Generate a SELECT query string.
*
* @param int $count_only
* @return string
*/
protected function _getSelectQueryString(int $count_only = 0): string
{
// Initialize the query string.
$result = 'SELECT';
$has_subquery_columns = false;
// Compose the column list.
if ($this->_column_list)
{
$column_list = implode(', ', array_map(function($str) {
return self::quoteName($str);
}, $this->_column_list));
}
else
{
$columns = array();
foreach ($this->columns as $column)
{
if ($column->ifvar && empty($this->_args[$column->ifvar]))
{
continue;
}
elseif ($column instanceof self)
{
$has_subquery_columns = true;
$subquery_count_only = $count_only ? $count_only + 1 : 0;
$subquery = $column->getQueryString($this->_prefix, $this->_args, [], $subquery_count_only);
foreach ($column->getQueryParams() as $param)
{
$this->_params[] = $param;
}
$columns[] = sprintf('(%s) AS %s', $subquery, self::quoteName($column->alias));
}
elseif ($column->is_expression && !$column->is_wildcard)
{
$columns[] = $column->name . ($column->alias ? (' AS ' . self::quoteName($column->alias)) : '');
}
elseif ($column->is_wildcard && $count_only >= 1 && !$this->select_distinct)
{
$columns[] = '1';
}
else
{
$columns[] = self::quoteName($column->name) . ($column->alias ? (' AS ' . self::quoteName($column->alias)) : '');
}
}
$column_list = implode(', ', $columns);
}
// Replace the column list if this is a count-only query.
if ($count_only == 1)
{
$count_wrap = ($this->groupby || $this->select_distinct || $has_subquery_columns || preg_match('/\bDISTINCT\b/i', $column_list));
if ($count_wrap)
{
$result .= ($this->select_distinct ? ' DISTINCT ' : ' ') . $column_list;
}
else
{
$result .= ' COUNT(*) AS `count`';
}
}
else
{
$count_wrap = false;
$result .= ($this->select_distinct ? ' DISTINCT ' : ' ') . $column_list;
}
// Compose the FROM clause.
if (count($this->tables))
{
$tables = $this->_arrangeTables($this->tables, self::ALIAS_ALWAYS);
if ($tables !== '')
{
$result .= ' FROM ' . $tables;
}
}
if (count($this->index_hints))
{
$index_hints = $this->_arrangeIndexHints($this->index_hints);
if ($index_hints !== '')
{
$result .= ' ' . $index_hints;
}
}
// Compose the WHERE clause.
if (count($this->conditions))
{
$where = $this->_arrangeConditions($this->conditions);
if ($where !== '')
{
$result .= ' WHERE ' . $where;
}
}
// Compose the GROUP BY clause.
if ($this->groupby && count($this->groupby->columns) && (!$this->groupby->ifvar || !empty($this->_args[$this->groupby->ifvar])))
{
$columns = array();
foreach ($this->groupby->columns as $column_name)
{
if (is_array($column_name))
{
list($column_name, $ifvar) = $column_name;
if ($ifvar && empty($this->_args[$ifvar]))
{
continue;
}
}
if (self::isValidColumnName($column_name))
{
$columns[] = self::quoteName($column_name);
}
else
{
$columns[] = $column_name;
}
}
$result .= ' GROUP BY ' . implode(', ', $columns);
}
if ($this->groupby && count($this->groupby->having) && (!$this->groupby->ifvar || !empty($this->_args[$this->groupby->ifvar])))
{
$having = $this->_arrangeConditions($this->groupby->having);
if ($having !== '')
{
$result .= ' HAVING ' . $having;
}
}
// Compose the ORDER BY clause.
if ($this->navigation && count($this->navigation->orderby) && !$count_only)
{
$order_by = $this->_arrangeOrderBy($this->navigation);
if ($order_by !== '')
{
$result .= ' ORDER BY ' . $order_by;
}
}
// Compose the LIMIT/OFFSET clause.
if ($this->navigation && $this->navigation->list_count && !$count_only)
{
$limit_offset = $this->_arrangeLimitOffset($this->navigation);
if ($limit_offset !== '')
{
$result .= ' LIMIT ' . $limit_offset;
}
}
// Wrap in a subquery if necesary.
if ($count_wrap)
{
$result = 'SELECT COUNT(*) AS `count` FROM (' . $result . ') AS `subquery`';
}
// Return the final query string.
return $result;
}
/**
* Generate a INSERT query string.
*
* @return string
*/
protected function _getInsertQueryString(): string
{
// Initialize the query string.
$result = 'INSERT';
// Compose the INTO clause.
if (count($this->tables))
{
$tables = $this->_arrangeTables($this->tables, self::ALIAS_NONE);
if ($tables !== '')
{
$result .= ' INTO ' . $tables;
}
}
// Process the SET clause with new values.
$columns = array();
foreach ($this->columns as $column)
{
if ($column->ifvar && empty($this->_args[$column->ifvar]))
{
continue;
}
$setval_string = $this->_parseCondition($column);
if ($setval_string !== '')
{
$columns[] = $setval_string;
}
}
$result .= ' SET ' . implode(', ', $columns);
// Process the ON DUPLICATE KEY UPDATE (upsert) clause.
if ($this->update_duplicate && count($columns))
{
$result .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $columns);
$duplicate_params = $this->_params;
foreach ($duplicate_params as $param)
{
$this->_params[] = $param;
}
}
// Return the final query string.
return $result;
}
/**
* Generate a UPDATE query string.
*
* @return string
*/
protected function _getUpdateQueryString(): string
{
// Initialize the query string.
$result = 'UPDATE ';
// Compose the INTO clause.
if (count($this->tables))
{
$tables = $this->_arrangeTables($this->tables, self::ALIAS_SPECIFIED);
if ($tables !== '')
{
$result .= $tables;
}
}
// Compose the SET clause with updated values.
$columns = array();
foreach ($this->columns as $column)
{
if ($column->ifvar && empty($this->_args[$column->ifvar]))
{
continue;
}
$setval_string = $this->_parseCondition($column);
if ($setval_string !== '')
{
$columns[] = $setval_string;
}
}
$result .= ' SET ' . implode(', ', $columns);
// Compose the WHERE clause.
if (count($this->conditions))
{
$where = $this->_arrangeConditions($this->conditions);
if ($where !== '')
{
$result .= ' WHERE ' . $where;
}
}
// Return the final query string.
return $result;
}
/**
* Generate a DELETE query string.
*
* @return string
*/
protected function _getDeleteQueryString(): string
{
// Initialize the query string.
$result = 'DELETE';
// Compose the FROM clause.
if (count($this->tables))
{
$tables = $this->_arrangeTables($this->tables, self::ALIAS_NONE);
if ($tables !== '')
{
$result .= ' FROM ' . $tables;
}
}
// Compose the WHERE clause.
if (count($this->conditions))
{
$where = $this->_arrangeConditions($this->conditions);
if ($where !== '')
{
$result .= ' WHERE ' . $where;
}
}
// Compose the ORDER BY clause.
if ($this->navigation && count($this->navigation->orderby))
{
$result .= ' ORDER BY ' . $this->_arrangeOrderBy($this->navigation);
}
// Compose the LIMIT/OFFSET clause.
if ($this->navigation && $this->navigation->list_count)
{
$limit_offset = $this->_arrangeLimitOffset($this->navigation);
if ($limit_offset !== '')
{
$result .= ' LIMIT ' . $limit_offset;
}
}
// Return the final query string.
return $result;
}
/**
* Generate a FROM clause from a list of tables.
*
* @param array $tables
* @param int $use_aliases
* @return string
*/
protected function _arrangeTables(array $tables, int $use_aliases = self::ALIAS_SPECIFIED): string
{
// Initialize the result.
$result = array();
// Process each table definition.
foreach ($tables as $table)
{
// Skip
if ($table->ifvar && empty($this->_args[$table->ifvar]))
{
continue;
}
// Subquery
if ($table instanceof self)
{
$tabledef = '(' . $table->getQueryString($this->_prefix, $this->_args) . ')';
$table->alias = $table->alias ?: $table->name;
if ($table->alias)
{
$tabledef .= ' AS `' . $table->alias . '`';
}
foreach ($table->getQueryParams() as $param)
{
$this->_params[] = $param;
}
}
// Regular table
else
{
$tabledef = self::quoteName($this->_prefix . $table->name);
if ($use_aliases === self::ALIAS_ALWAYS)
{
$table->alias = $table->alias ?: $table->name;
}
if ($use_aliases !== self::ALIAS_NONE && $table->alias && $table->alias !== ($this->_prefix . $table->name))
{
$tabledef .= ' AS `' . $table->alias . '`';
}
}
// Add join conditions
if ($table->join_type)
{
$join_where = $this->_arrangeConditions($table->join_conditions);
if ($join_where !== '')
{
$tabledef = $tabledef . ' ON ' . $join_where;
}
$result[] = ' ' . $table->join_type . ' ' . $tabledef;
}
else
{
$result[] = (count($result) ? ', ' : '') . $tabledef;
}
}
// Combine the result and return as a string.
return implode('', $result);
}
/**
* Generate index hints.
*
* @param array $index_hints
* @return string
*/
protected function _arrangeIndexHints(array $index_hints): string
{
// Initialize the index list by type.
$index_list = [];
// Group each index hint by type.
foreach ($index_hints as $index_hint)
{
// Skip
if ($index_hint->ifvar && empty($this->_args[$index_hint->ifvar]))
{
continue;
}
if (!count($index_hint->target_db) || isset($index_hint->target_db['mysql']))
{
$key = $index_hint->hint_type ?: 'USE';
$index_list[$key] = $index_list[$key] ?? [];
if ($index_hint->var && isset($this->_args[$index_hint->var]))
{
$index_list[$key][] = self::quoteName($this->_args[$index_hint->var]);
}
elseif ($index_hint->index_name)
{
$index_list[$key][] = self::quoteName($index_hint->index_name);
}
}
}
// Generate a list of indexes for each group.
$result = [];
foreach ($index_list as $key => $val)
{
if (count($val))
{
$result[] = sprintf('%s INDEX (%s)', $key, implode(', ', $val));
}
}
return implode(' ', $result);
}
/**
* Generate a WHERE clause from a list of conditions.
*
* @param array $conditions
* @return string
*/
protected function _arrangeConditions(array $conditions): string
{
// Initialize the result.
$result = '';
// Process each condition.
foreach ($conditions as $condition)
{
// Skip
if ($condition->ifvar && empty($this->_args[$condition->ifvar]))
{
continue;
}
// Subquery
if ($condition instanceof self)
{
$condition_string = $this->_parseCondition($condition);
if ($condition_string !== '')
{
$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . $condition_string;
}
}
// Condition group
elseif ($condition instanceof ConditionGroup)
{
$condition_string = $this->_arrangeConditions($condition->conditions);
if ($condition_string !== '')
{
$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . '(' . $condition_string . ')';
}
elseif ($condition->not_null)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Condition group marked as NOT NULL must contain at least one valid condition');
}
}
// Simple condition
else
{
$condition_string = $this->_parseCondition($condition);
if ($condition_string !== '')
{
$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . $condition_string;
}
}
}
// Return the WHERE clause.
return $result;
}
/**
* Generate a ORDER BY clause from navigation settings.
*
* @param Navigation $navigation
* @return string
*/
protected function _arrangeOrderBy(Navigation $navigation): string
{
// Initialize the result.
$result = array();
// Process each column definition.
foreach ($navigation->orderby as $orderby)
{
// Skip
if ($orderby->ifvar && empty($this->_args[$orderby->ifvar]))
{
continue;
}
// Get the name of the column or expression to order by.
$column_name = '';
list($column_name, $is_expression, $is_default_value) = $orderby->getValue($this->_args);
if (!$column_name)
{
continue;
}
if (!$is_expression && self::isValidColumnName($column_name))
{
$column_name = self::quoteName($column_name);
}
elseif (!$is_default_value)
{
continue;
}
// Get the ordering (ASC or DESC).
if (preg_match('/^(ASC|DESC)$/i', $orderby->order_var ?: '', $matches))
{
$column_order = strtoupper($matches[1]);
}
elseif (isset($this->_args[$orderby->order_var]))
{
$column_order = preg_replace('/[^A-Z]/', '', strtoupper($this->_args[$orderby->order_var]));
}
else
{
$column_order = preg_replace('/[^A-Z]/', '', strtoupper($orderby->order_default));
}
$result[] = $column_name . ' ' . $column_order;
}
// Return the ORDER BY clause.
return implode(', ', $result);
}
/**
* Generate a LIMIT/OFFSET clause from navigation settings.
*
* @param Navigation $navigation
* @return string
*/
protected function _arrangeLimitOffset(Navigation $navigation): string
{
$list_count = 0;
$page = 0;
$offset = 0;
// Get the list count.
if (!$navigation->list_count->ifvar || !empty($this->_args[$navigation->list_count->ifvar]))
{
$list_count = $navigation->list_count->getValue($this->_args)[0];
}
if ($list_count <= 0)
{
return '';
}
// Get the offset from the page or offset variable.
if ($navigation->page)
{
if (!$navigation->page->ifvar || !empty($this->_args[$navigation->page->ifvar]))
{
$page = $navigation->page->getValue($this->_args)[0];
}
}
if ($navigation->offset)
{
if (!$navigation->offset->ifvar || !empty($this->_args[$navigation->offset->ifvar]))
{
$offset = $navigation->offset->getValue($this->_args)[0];
}
}
// If page is available, set the offset and require pagination for this query.
if ($page > 0)
{
$offset = $list_count * ($page - 1);
if ($this->type === 'SELECT')
{
$this->requires_pagination = true;
}
}
else
{
$page = 1;
}
// Return the LIMIT/OFFSET clause.
return ($offset > 0 ? (intval($offset) . ', ') : '') . intval($list_count);
}
/**
* Generate each condition in a WHERE clause.
*
* @param VariableBase $condition
* @return string
*/
protected function _parseCondition(VariableBase $condition): string
{
list($where, $params) = $condition->getQueryStringAndParams($this->_args, $this->_prefix);
foreach ($params as $param)
{
$this->_params[] = $param;
}
return $where;
}
/**
* Quote a column name.
*
* @param string $column_name
* @return string
*/
public static function quoteName(string $column_name): string
{
$exceptions = ['*' => true, 'DISTINCT' => true, 'distinct' => true];
return preg_replace_callback('/[a-z][a-z0-9_.*]*(?!\\()\b/i', function($m) use($exceptions) {
$columns = explode('.', $m[0]);
$columns = array_map(function($str) use($exceptions) {
return isset($exceptions[$str]) ? $str : ('`' . $str . '`');
}, $columns);
return implode('.', $columns);
}, $column_name);
}
/**
* Check if a column name is valid.
*
* @param string $column_name
* @return bool
*/
public static function isValidColumnName(string $column_name): bool
{
return preg_match('/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/i', $column_name) ? true : false;
}
/**
* Check if a variable is considered valid for XE compatibility.
*
* @param mixed $var
* @param bool $allow_empty_string
* @return bool
*/
public static function isValidVariable($var, bool $allow_empty_string = true): bool
{
if ($var === null || ($var === '' && !$allow_empty_string))
{
return false;
}
if (is_array($var))
{
$count = count($var);
if ($count === 0 || ($count === 1 && reset($var) === ''))
{
return false;
}
}
return true;
}
}