More work on the query parser

This commit is contained in:
Kijin Sung 2020-06-26 16:52:41 +09:00
parent 92ff69591f
commit 6eca8736c1
6 changed files with 422 additions and 76 deletions

View file

@ -0,0 +1,18 @@
<?php
namespace Rhymix\Framework\Exceptions;
/**
* The Query Error exception class.
*/
class QueryError extends \Rhymix\Framework\Exception
{
public function __construct($message = '', $code = 0, $previous = null)
{
if ($message === '')
{
$message = 'Query Error';
}
parent::__construct($message, $code, $previous);
}
}

View file

@ -1,49 +0,0 @@
<?php
namespace Rhymix\Framework\Parsers\DBQuery;
/**
* Expression class.
*/
class Expression
{
/**
* Instance properties.
*/
public $type;
public $value;
/**
* Constructor.
*
* @param string $type
* @param mixed $value
*/
public function __construct(string $type, $value = null)
{
$this->type = $type;
$this->value = $value;
}
/**
* Return the string representation of this expression.
*
* @return string
*/
public function __toString()
{
switch ($this->type)
{
case 'var':
return ':' . $this->value;
case 'null':
return 'NULL';
case 'string':
return;
case 'int':
return $this->value;
}
}
}

View file

@ -18,4 +18,215 @@ class Query
public $conditions = array();
public $groupby = null;
public $navigation = null;
/**
* Attributes for query generation.
*/
protected $_prefix = '';
protected $_args = array();
protected $_column_list = array();
protected $_params = array();
protected $_temp_num = 0;
/**
* Generate the query string for this query.
*
* @param string $prefix
* @param array $args
* @param array $column_list
* @return string
*/
public function getQueryString(string $prefix = '', array $args, array $column_list = []): string
{
// Save the query information.
$this->_prefix = $prefix;
$this->_args = $args;
$this->_column_list = $column_list;
$this->_temp_num = 0;
// Call different internal methods depending on the query type.
switch ($this->type)
{
case 'SELECT':
return $this->_getSelectQueryString();
default:
return '';
}
}
/**
* Get the query parameters to use with the query string generated above.
*
* @return array
*/
public function getQueryParams()
{
return $this->_params;
}
/**
* Generate a SELECT query string.
*
* @return string
*/
protected function _getSelectQueryString(): string
{
// Initialize the query string.
$result = 'SELECT ';
// Compose the column list.
$columns = array();
if ($this->_column_list)
{
$result .= implode(', ', array_map(function($str) {
return '`' . $str . '`';
}, $this->_column_list));
}
else
{
foreach ($this->columns as $column)
{
if ($column instanceof self)
{
$subquery = $column->getQueryString($this->_prefix, $this->_args);
foreach ($column->getQueryParams() as $key => $val)
{
$this->_params[$key] = $val;
}
$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)) : '');
}
else
{
$columns[] = self::quoteName($column->name) . ($column->alias ? (' AS ' . self::quoteName($column->alias)) : '');
}
}
$result .= implode(', ', $columns);
}
// Compose the table list.
$tables = array();
foreach ($this->tables as $table)
{
if ($table instanceof self)
{
$subquery = $table->getQueryString($this->_prefix, $this->_args);
foreach ($table->getQueryParams() as $key => $val)
{
$this->_params[$key] = $val;
}
$tables[] = sprintf('(%s) AS `%s`', $subquery, $table->alias);
}
else
{
$tabledef = self::quoteName($table->name) . ($table->alias ? (' AS `' . $table->alias . '`') : '');
if ($table->join_type)
{
$tabledef = $table->join_type . ' ' . $tabledef;
$join_where = $this->_arrangeConditions($table->join_conditions);
if ($join_where !== '')
{
$tabledef = $tabledef . ' ON ' . $join_where;
}
}
$tables[] = $tabledef;
}
}
$result .= ' FROM ' . implode(', ', $tables);
// Compose the conditions.
if (count($this->conditions))
{
$where = $this->_arrangeConditions($this->conditions);
if ($where !== '')
{
$result .= ' WHERE ' . $where;
}
}
// Compose the GROUP BY clause.
// Compose the LIMIT clause.
// Return the final query string.
return $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)
{
// Subquery
if ($condition instanceof self)
{
// TODO
}
// Condition group
elseif ($condition instanceof ConditionGroup)
{
$condition_string = $this->_arrangeConditions($condition->conditions);
if ($condition_string === '')
{
continue;
}
$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . '(' . $condition_string . ')';
}
// Simple condition
else
{
$condition_string = $this->_parseCondition($condition);
if ($condition_string === '')
{
continue;
}
$result .= ($result === '' ? '' : (' ' . $condition->pipe . ' ')) . $condition_string;
}
}
// Return the WHERE clause.
return $result;
}
/**
* Generate each condition in a WHERE clause.
*
* @param object $condition
* @return string
*/
protected function _parseCondition(Condition $condition): string
{
list($where, $params) = $condition->getQueryStringAndParams($this->_args);
foreach ($params as $key => $val)
{
$this->_params[$key] = $val;
}
return $where;
}
/**
* Quote a column name.
*/
public static function quoteName($column_name): string
{
$columns = explode('.', $column_name);
$columns = array_map(function($str) {
return $str === '*' ? $str : ('`' . $str . '`');
}, $columns);
return implode('.', $columns);
}
}

View file

@ -14,63 +14,229 @@ class VariableBase
public $default;
/**
* Get the value of this variable.
* Convert an operator into real SQL.
*
* @return Expression
* @param array $args
* @return array
*/
public function getValue(): Expression
public function getQueryStringAndParams(array $args): array
{
if ($this->var)
// Return if this method is called on an invalid child class.
if (!isset($this->column) || !isset($this->operation))
{
return new Expression('var', $this->var);
throw new \Rhymix\Framework\Exceptions\QueryError('Invalid invocation of getQueryStringAndParams()');
}
// Process the variable or default value.
if ($this->var && isset($args[$this->var]) && !empty($args[$this->var]))
{
$this->filterValue($args[$this->var]);
$value = $args[$this->var];
}
elseif ($this->default !== null)
{
$value = $this->getDefaultValue();
}
elseif ($this->not_null)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' is not set');
}
else
{
return $this->getDefaultValue();
return ['', []];
}
// Quote the column name.
$column = Query::quoteName($this->column);
$where = '';
$params = array();
// Prepare the target value.
$list_ops = array('in' => true, 'notin' => true, 'not_in' => true, 'between' => true);
if (isset($list_ops[$this->operation]) && !is_array($value) && $value !== '')
{
$value = explode(',', preg_replace('/[\s\']/', '', $value));
}
// Apply the operator.
switch ($this->operation)
{
case 'equal':
$where = sprintf('%s = ?', $column);
$params[] = $value;
break;
case 'like':
$where = sprintf('%s LIKE ?', $column);
$params[] = '%' . $value . '%';
break;
case 'like_prefix':
case 'like_head':
$where = sprintf('%s LIKE ?', $column);
$params[] = $value . '%';
break;
case 'like_suffix':
case 'like_tail':
$where = sprintf('%s LIKE ?', $column);
$params[] = '%' . $value;
break;
case 'notlike':
$where = sprintf('%s NOT LIKE ?', $column);
$params[] = '%' . $value . '%';
break;
case 'notlike_prefix':
case 'notlike_head':
$where = sprintf('%s NOT LIKE ?', $column);
$params[] = $value . '%';
break;
case 'notlike_suffix':
case 'notlike_tail':
$where = sprintf('%s NOT LIKE ?', $column);
$params[] = '%' . $value;
break;
case 'in':
$count = count($value);
$placeholders = implode(', ', array_fill(0, $count, '?'));
$where = sprintf('%s IN (%s)', $column, $placeholders);
foreach ($value as $item)
{
$params[] = $item;
}
break;
case 'notin':
case 'not_in':
$count = count($value);
$placeholders = implode(', ', array_fill(0, $count, '?'));
$where = sprintf('%s NOT IN (%s)', $column, $placeholders);
foreach ($value as $item)
{
$params[] = $item;
}
break;
case 'between':
$where = sprintf('%s BETWEEN ? AND ?', $column);
foreach ($value as $item)
{
$params[] = $item;
}
break;
}
// Return the complete condition and parameters.
return [$where, $params];
}
/**
* Get the default value of this variable.
*
* @return Expression
* @return mixed
*/
public function getDefaultValue(): Expression
public function getDefaultValue()
{
// If the default value is not set, return null.
$val = $this->default;
if ($val === null)
// If the default value is a column name, escape it.
if (preg_match('/^[a-z0-9_]+(?:\.[a-z0-9_]+)+$/', $this->default))
{
return new Expression('null');
return Query::quoteName($this->default);
}
elseif (isset($this->column) && preg_match('/_srl$/', $this->column) && !ctype_digit($this->default))
{
return Query::quoteName($this->default);
}
// If the default value is a function shortcut, return an appropriate value.
switch ($val)
switch ($this->default)
{
case 'ipaddress()':
return new Expression('string', \RX_CLIENT_IP);
return "'" . \RX_CLIENT_IP . "'";
case 'unixtime()':
return new Expression('string', time());
return time();
case 'curdate()':
case 'date()':
return new Expression('string', date('YmdHis'));
return "'" . date('YmdHis') . "'";
case 'sequence()':
return new Expression('int', getNextSequence());
return getNextSequence();
}
// If the default value is a calculation based on the current value, return a query string.
if (isset($this->column) && preg_match('/^(plus|minus|multiply)\(([0-9]+)\)$/', $val, $matches))
if (isset($this->column) && preg_match('/^(plus|minus|multiply)\(([0-9]+)\)$/', $this->default, $matches))
{
switch ($matches[1])
{
case 'plus':
return sprintf('%s + %d', Query::quoteName($this->column), $matches[2]);
case 'minus':
return sprintf('%s - %d', Query::quoteName($this->column), $matches[2]);
case 'multiply':
return sprintf('%s * %d', Query::quoteName($this->column), $matches[2]);
}
}
// If the default value is a column name, return the column name.20
if (\Rhymix\Framework\Parsers\DBQueryParser::isValidColumnName($val))
// Otherwise, just return the literal value.
return $this->default;
}
/**
* Filter a value.
*
* @param mixed $value
* @return void
*/
public function filterValue($value)
{
// Apply filters.
switch (isset($this->filter) ? $this->filter : '')
{
case 'email':
case 'email_address':
if (!preg_match('/^[\w-]+((?:\.|\+|\~)[\w-]+)*@[\w-]+(\.[\w-]+)+$/', $value))
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain a valid e-mail address');
}
break;
case 'homepage':
case 'url':
if (!preg_match('/^(http|https)+(:\/\/)+[0-9a-z_-]+\.[^ ]+$/i', $value))
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain a valid URL');
}
break;
case 'userid':
case 'user_id':
if (!preg_match('/^[a-zA-Z]+([_0-9a-zA-Z]+)*$/', $value))
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain a valid user ID');
}
break;
case 'number':
case 'numbers':
if (!preg_match('/^(-?)[0-9]+(,\-?[0-9]+)*$/', is_array($value) ? implode(',', $value) : $value))
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain a valid number');
}
break;
case 'alpha':
if (!ctype_alpha($value))
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain only alphabets');
}
break;
case 'alnum':
case 'alpha_number':
if (!ctype_alnum($value))
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain only alphanumeric characters');
}
break;
}
// Otherwise, return the literal value.
return new Expression('string', $val);
// Check minimum and maximum lengths.
$length = iconv_strlen($value, 'UTF-8');
if (isset($this->minlength) && $this->minlength > 0 && $length < $this->minlength)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain no less than ' . $this->minlength . ' characters');
}
if (isset($this->maxlength) && $this->maxlength > 0 && $length > $this->maxlength)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $this->column . ' must contain no more than ' . $this->minlength . ' characters');
}
}
}

View file

@ -45,11 +45,11 @@ class DBQueryParser
{
$query->name = $query->alias;
}
$query->type = strtoupper($xml['action']) ?: null;
$query->type = strtoupper($xml['action']) ?: 'SELECT';
// Load attributes that only apply to subqueries in the <conditions> block.
$query->operation = trim($xml['operation']) ?: null;
$query->column = trim($xml['column']) ?: null;
$query->column = preg_replace('/[^a-z0-9_\.]/i', '', $xml['column']) ?: null;
$query->pipe = strtoupper($xml['pipe']) ?: 'AND';
// Load tables.

View file

@ -21,7 +21,7 @@ class Table
* @param string $engine
* @return string
*/
public function getCreateQuery(string $prefix = '', string $charset = 'utf8mb4', string $engine = 'innodb')
public function getCreateQuery(string $prefix = '', string $charset = 'utf8mb4', string $engine = 'innodb'): string
{
// Initialize the query.
$result = 'CREATE TABLE `' . $prefix . $this->name . '` (';