re-design query parser, new model has cleaner code and allows for %% escaping of % characters

This commit is contained in:
Sergey Tsalkov
2021-06-18 20:50:25 +00:00
parent 20c7acd8a4
commit 42894b70ae
3 changed files with 202 additions and 172 deletions

View File

@@ -105,7 +105,6 @@ class MeekroDB {
public $current_db = null;
public $nested_transactions_count = 0;
public function __construct($host=null, $user=null, $password=null, $dbName=null, $port=null, $encoding=null, $socket=null) {
if ($host === null) $host = DB::$host;
if ($user === null) $user = DB::$user;
@@ -261,7 +260,7 @@ class MeekroDB {
return $this->nested_transactions_count;
}
protected function formatTableName($table) {
function formatTableName($table) {
$table = trim($table, '`');
if (strpos($table, '.')) return implode('.', array_map(array($this, 'formatTableName'), explode('.', $table)));
@@ -273,12 +272,14 @@ class MeekroDB {
$table = array_shift($args);
$params = array_shift($args);
$update_part = $this->parseQueryParams(
$update_part = $this->parse(
str_replace('%', $this->param_char, "UPDATE %b SET %hc"),
$table, $params
);
$where_part = call_user_func_array(array($this, 'parseQueryParams'), $args);
// we don't know if they used named or numbered args, so the where clause
// must be run through the parser separately
$where_part = call_user_func_array(array($this, 'parse'), $args);
$query = $update_part . ' WHERE ' . $where_part;
return $this->query($query);
}
@@ -353,14 +354,14 @@ class MeekroDB {
$args = func_get_args();
$table = $this->formatTableName(array_shift($args));
$where = call_user_func_array(array($this, 'parseQueryParams'), $args);
$where = call_user_func_array(array($this, 'parse'), $args);
$query = "DELETE FROM {$table} WHERE {$where}";
return $this->query($query);
}
public function sqleval() {
$args = func_get_args();
$text = call_user_func_array(array($this, 'parseQueryParams'), $args);
$text = call_user_func_array(array($this, 'parse'), $args);
return new MeekroDBEval($text);
}
@@ -391,106 +392,189 @@ class MeekroDB {
return $result;
}
protected function preparseQueryParams() {
$args = func_get_args();
$sql = trim(strval(array_shift($args)));
$args_all = $args;
protected function paramsMap() {
$t = $this;
if (count($args_all) == 0) return array($sql);
return array(
's' => function($arg) use ($t) { return $t->escape($arg); },
'i' => function($arg) use ($t) { return $t->intval($arg); },
'd' => function($arg) use ($t) { return doubleval($arg); },
'b' => function($arg) use ($t) { return $t->formatTableName($arg); },
'l' => function($arg) use ($t) { return strval($arg); },
't' => function($arg) use ($t) { return $t->escapeTS($arg); },
'ss' => function($arg) use ($t) { return $t->escape("%" . str_replace(array('%', '_'), array('\%', '\_'), $arg) . "%"); },
$param_char_length = strlen($this->param_char);
$named_seperator_length = strlen($this->named_param_seperator);
'ls' => function($arg) use ($t) { return array_map(array($t, 'escape'), $arg); },
'li' => function($arg) use ($t) { return array_map(array($t, 'intval'), $arg); },
'ld' => function($arg) use ($t) { return array_map('doubleval', $arg); },
'lb' => function($arg) use ($t) { return array_map(array($t, 'formatTableName'), $arg); },
'll' => function($arg) use ($t) { return array_map('strval', $arg); },
'lt' => function($arg) use ($t) { return array_map(array($t, 'escapeTS'), $arg); },
$types = array(
$this->param_char . 'll', // list of literals
$this->param_char . 'ls', // list of strings
$this->param_char . 'l', // literal
$this->param_char . 'li', // list of integers
$this->param_char . 'ld', // list of decimals
$this->param_char . 'lb', // list of backticks
$this->param_char . 'lt', // list of timestamps
$this->param_char . 's', // string
$this->param_char . 'i', // integer
$this->param_char . 'd', // double / decimal
$this->param_char . 'b', // backtick
$this->param_char . 't', // timestamp
$this->param_char . '?', // infer type
$this->param_char . 'l?', // list of inferred types
$this->param_char . 'll?', // list of lists of inferred types
$this->param_char . 'hc', // hash `key`='value' pairs separated by commas
$this->param_char . 'ha', // hash `key`='value' pairs separated by and
$this->param_char . 'ho', // hash `key`='value' pairs separated by or
$this->param_char . 'ss', // search string (like string, surrounded with %'s)
$this->param_char . 'ssb', // search string (like, begins with)
$this->param_char . 'sse', // search string (like, ends with)
'?' => function($arg) use ($t) { return $t->sanitize($arg); },
'l?' => function($arg) use ($t) { return $t->sanitize($arg, 'list'); },
'll?' => function($arg) use ($t) { return $t->sanitize($arg, 'doublelist'); },
'hc' => function($arg) use ($t) { return $t->sanitize($arg, 'hash'); },
'ha' => function($arg) use ($t) { return $t->sanitize($arg, 'hash', ' AND '); },
'ho' => function($arg) use ($t) { return $t->sanitize($arg, 'hash', ' OR '); },
$this->param_char => function($arg) use ($t) { return $t->param_char; },
);
}
// generate list of all MeekroDB variables in our query, and their position
// in the form "offset => variable", sorted by offsets
$posList = array();
foreach ($types as $type) {
$lastPos = 0;
while (($pos = strpos($sql, $type, $lastPos)) !== false) {
$lastPos = $pos + 1;
if (isset($posList[$pos]) && strlen($posList[$pos]) > strlen($type)) continue;
$posList[$pos] = $type;
protected function nextQueryParam($query) {
$keys = array_keys($this->paramsMap());
$first_position = PHP_INT_MAX;
$first_param = null;
$first_type = null;
$arg = null;
$named_arg = null;
foreach ($keys as $key) {
$fullkey = $this->param_char . $key;
$pos = strpos($query, $fullkey);
if ($pos === false) continue;
if ($pos <= $first_position) {
$first_position = $pos;
$first_param = $fullkey;
$first_type = $key;
}
}
ksort($posList);
if (is_null($first_param)) return;
// for each MeekroDB variable, substitute it with array(type: i, value: 53) or whatever
$chunkyQuery = array(); // preparsed query
$pos_adj = 0; // how much we've added or removed from the original sql string
foreach ($posList as $pos => $type) {
$type = substr($type, $param_char_length); // variable, without % in front of it
$length_type = strlen($type) + $param_char_length; // length of variable w/o %
$first_position_end = $first_position + strlen($first_param);
$named_seperator_length = strlen($this->named_param_seperator);
$arg_mask = '0123456789';
$named_arg_mask = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_';
$new_pos = $pos + $pos_adj; // position of start of variable
$new_pos_back = $new_pos + $length_type; // position of end of variable
$arg_number_length = 0; // length of any named or numbered parameter addition
if ($arg_number_length = strspn($query, $arg_mask, $first_position_end)) {
$arg = intval(substr($query, $first_position_end, $arg_number_length));
$first_param = substr($query, $first_position, strlen($first_param) + $arg_number_length);
}
else if (substr($query, $first_position_end, $named_seperator_length) == $this->named_param_seperator) {
$named_arg_length = strspn($query, $named_arg_mask, $first_position_end + $named_seperator_length);
// handle numbered parameters
if ($arg_number_length = strspn($sql, '0123456789', $new_pos_back)) {
$arg_number = substr($sql, $new_pos_back, $arg_number_length);
if (! array_key_exists($arg_number, $args_all)) return $this->nonSQLError("Non existent argument reference (arg $arg_number): $sql");
if ($named_arg_length > 0) {
$named_arg = substr($query, $first_position_end + $named_seperator_length, $named_arg_length);
$first_param = substr($query, $first_position, strlen($first_param) + $named_seperator_length + $named_arg_length);
}
}
$arg = $args_all[$arg_number];
return array(
'param' => $first_param,
'type' => $first_type,
'pos' => $first_position,
'arg' => $arg,
'named_arg' => $named_arg,
);
}
// handle named parameters
} else if (substr($sql, $new_pos_back, $named_seperator_length) == $this->named_param_seperator) {
$arg_number_length = strspn($sql, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_',
$new_pos_back + $named_seperator_length) + $named_seperator_length;
function parse($query) {
$args = func_get_args();
array_shift($args);
$query = trim($query);
$arg_number = substr($sql, $new_pos_back + $named_seperator_length, $arg_number_length - $named_seperator_length);
if (count($args_all) != 1 || !is_array($args_all[0])) return $this->nonSQLError("If you use named parameters, the second argument must be an array of parameters");
if (! array_key_exists($arg_number, $args_all[0])) return $this->nonSQLError("Non existent argument reference (arg $arg_number): $sql");
if (! $args) return $query;
$arg = $args_all[0][$arg_number];
$arg_ct = 0;
$max_numbered_arg = 0;
$use_numbered_args = false;
$use_named_args = false;
} else {
$arg_number = 0;
$arg = array_shift($args);
$queryParts = array();
while ($Param = $this->nextQueryParam($query)) {
if ($Param['pos'] > 0) {
$queryParts[] = substr($query, 0, $Param['pos']);
}
if ($new_pos > 0) $chunkyQuery[] = substr($sql, 0, $new_pos);
if ($Param['type'] != $this->param_char && is_null($Param['arg']) && is_null($Param['named_arg'])) {
$Param['arg'] = $arg_ct++;
}
if (is_object($arg) && ($arg instanceof WhereClause)) {
list($clause_sql, $clause_args) = $arg->textAndArgs();
if (! is_null($Param['arg'])) {
$use_numbered_args = true;
$max_numbered_arg = max($max_numbered_arg, $Param['arg']);
}
if (! is_null($Param['named_arg'])) {
$use_named_args = true;
}
$queryParts[] = $Param;
$query = substr($query, $Param['pos'] + strlen($Param['param']));
}
if (strlen($query) > 0) {
$queryParts[] = $query;
}
if ($use_named_args && $use_numbered_args) {
return $this->nonSQLError("You can't mix named and numbered args!");
}
if ($use_named_args && count($args) != 1) {
return $this->nonSQLError("If you use named args, you must pass an assoc array of args!");
}
if ($use_numbered_args && $max_numbered_arg+1 > count($args)) {
return $this->nonSQLError(sprintf('Expected %d args, but only got %d!', $max_numbered_arg+1, count($args)));
}
$array_types = array('ls', 'li', 'ld', 'lb', 'll', 'lt', 'l?', 'll?', 'hc', 'ha', 'ho');
$Map = $this->paramsMap();
$query = '';
foreach ($queryParts as $Part) {
if (is_string($Part)) {
$query .= $Part;
continue;
}
$fn = $Map[$Part['type']];
$is_array_type = in_array($Part['type'], $array_types, true);
$val = null;
if ($use_named_args && !is_null($Part['named_arg'])) {
$key = $Part['named_arg'];
if (! array_key_exists($key, $args[0])) {
return $this->nonSQLError("Couldn't find named arg {$key}!");
}
$val = $args[0][$key];
}
else if ($use_numbered_args && !is_null($Part['arg'])) {
$key = $Part['arg'];
$val = $args[$key];
}
if ($is_array_type && !is_array($val)) {
return $this->nonSQLError("Expected an array for arg $key but didn't get one!");
}
if ($is_array_type && count($val) == 0) {
return $this->nonSQLError("Arg {$key} array can't be empty!");
}
if (!$is_array_type && is_array($val)) {
$val = '';
}
if (is_object($val) && ($val instanceof WhereClause)) {
if ($Part['type'] != 'l') {
return $this->nonSQLError("WhereClause must be used with l arg, you used {$Part['type']} instead!");
}
list($clause_sql, $clause_args) = $val->textAndArgs();
array_unshift($clause_args, $clause_sql);
$preparsed_sql = call_user_func_array(array($this, 'preparseQueryParams'), $clause_args);
$chunkyQuery = array_merge($chunkyQuery, $preparsed_sql);
} else {
$chunkyQuery[] = array('type' => $type, 'value' => $arg);
$result = call_user_func_array(array($this, 'parse'), $clause_args);
}
else {
$result = $fn($val);
if (is_array($result)) $result = '(' . implode(',', $result) . ')';
}
$sql = substr($sql, $new_pos_back + $arg_number_length);
$pos_adj -= $new_pos_back + $arg_number_length;
$query .= $result;
}
if (strlen($sql) > 0) $chunkyQuery[] = $sql;
return $chunkyQuery;
return $query;
}
public function escape($str) { return "'" . $this->get()->real_escape_string(strval($str)) . "'"; }
@@ -545,69 +629,20 @@ class MeekroDB {
}
protected function parseTS($ts) {
if (is_string($ts)) return date('Y-m-d H:i:s', strtotime($ts));
else if (is_object($ts) && ($ts instanceof DateTime)) return $ts->format('Y-m-d H:i:s');
}
protected function intval($var) {
if (PHP_INT_SIZE == 8) return intval($var);
return floor(doubleval($var));
}
public function parseQueryParams() {
$args = func_get_args();
$chunkyQuery = call_user_func_array(array($this, 'preparseQueryParams'), $args);
$query = '';
$array_types = array('ls', 'li', 'ld', 'lb', 'll', 'lt', 'l?', 'll?', 'hc', 'ha', 'ho');
foreach ($chunkyQuery as $chunk) {
if (is_string($chunk)) {
$query .= $chunk;
continue;
}
$type = $chunk['type'];
$arg = $chunk['value'];
$result = '';
$is_array_type = in_array($type, $array_types, true);
if ($is_array_type && !is_array($arg)) return $this->nonSQLError("Badly formatted SQL query: Expected array, got scalar instead!");
else if (!$is_array_type && is_array($arg)) $arg = '';
if ($type == 's') $result = $this->escape($arg);
else if ($type == 'i') $result = $this->intval($arg);
else if ($type == 'd') $result = doubleval($arg);
else if ($type == 'b') $result = $this->formatTableName($arg);
else if ($type == 'l') $result = $arg;
else if ($type == 'ss') $result = $this->escape("%" . str_replace(array('%', '_'), array('\%', '\_'), $arg) . "%");
else if ($type == 'ssb') $result = $this->escape(str_replace(array('%', '_'), array('\%', '\_'), $arg) . "%");
else if ($type == 'sse') $result = $this->escape("%" . str_replace(array('%', '_'), array('\%', '\_'), $arg));
else if ($type == 't') $result = $this->escape($this->parseTS($arg));
else if ($type == 'ls') $result = array_map(array($this, 'escape'), $arg);
else if ($type == 'li') $result = array_map(array($this, 'intval'), $arg);
else if ($type == 'ld') $result = array_map('doubleval', $arg);
else if ($type == 'lb') $result = array_map(array($this, 'formatTableName'), $arg);
else if ($type == 'll') $result = $arg;
else if ($type == 'lt') $result = array_map(array($this, 'escape'), array_map(array($this, 'parseTS'), $arg));
else if ($type == '?') $result = $this->sanitize($arg);
else if ($type == 'l?') $result = $this->sanitize($arg, 'list');
else if ($type == 'll?') $result = $this->sanitize($arg, 'doublelist');
else if ($type == 'hc') $result = $this->sanitize($arg, 'hash');
else if ($type == 'ha') $result = $this->sanitize($arg, 'hash', ' AND ');
else if ($type == 'ho') $result = $this->sanitize($arg, 'hash', ' OR ');
else return $this->nonSQLError("Badly formatted SQL query: Invalid MeekroDB param $type");
if (is_array($result)) $result = '(' . implode(',', $result) . ')';
$query .= $result;
function escapeTS($ts) {
if (is_string($ts)) {
$str = date('Y-m-d H:i:s', strtotime($ts));
}
else if (is_object($ts) && ($ts instanceof DateTime)) {
$str = $ts->format('Y-m-d H:i:s');
}
return $query;
return $this->escape($str);
}
function intval($var) {
if (PHP_INT_SIZE == 8) return intval($var);
return floor(doubleval($var));
}
protected function prependCall($function, $args, $prepend) { array_unshift($args, $prepend); return call_user_func_array($function, $args); }
@@ -648,7 +683,7 @@ class MeekroDB {
return $this->nonSQLError('Error -- invalid argument to queryHelper!');
}
$sql = call_user_func_array(array($this, 'parseQueryParams'), $args);
$sql = call_user_func_array(array($this, 'parse'), $args);
if ($this->pre_sql_handler !== false && is_callable($this->pre_sql_handler)) {
$sql = call_user_func($this->pre_sql_handler, $sql);
@@ -723,11 +758,6 @@ class MeekroDB {
return $return;
}
public function parse() {
$args = func_get_args();
return call_user_func_array(array($this, 'parseQueryParams'), $args);
}
public function queryFirstRow() {
$args = func_get_args();
$result = call_user_func_array(array($this, 'query'), $args);
@@ -835,10 +865,6 @@ class WhereClause {
if ($this->negate) $sql = '(NOT ' . $sql . ')';
return array($sql, $args);
}
// backwards compatability
// we now return full WhereClause object here and evaluate it in preparseQueryParams
function text() { return $this; }
}
class DBTransaction {

View File

@@ -417,6 +417,10 @@ class BasicTest extends SimpleTest {
$parsed_query = DB::parse("SELECT * FROM %b WHERE id=%i AND name=%s", 'accounts', 5, 'Joe');
$correct_query = "SELECT * FROM `accounts` WHERE id=5 AND name='Joe'";
$this->assert($parsed_query === $correct_query);
$parsed_query = DB::parse("SELECT DATE_FORMAT(birthday, '%%Y-%%M-%%d %%h:%%i:%%s') AS mydate FROM accounts WHERE id=%i", 5);
$correct_query = "SELECT DATE_FORMAT(birthday, '%Y-%M-%d %h:%i:%s') AS mydate FROM accounts WHERE id=5";
$this->assert($parsed_query === $correct_query);
}

View File

@@ -5,7 +5,7 @@ class WhereClauseTest extends SimpleTest {
$where->add('username=%s', 'Bart');
$where->add('password=%s', 'hello');
$result = DB::query("SELECT * FROM accounts WHERE %l", $where->text());
$result = DB::query("SELECT * FROM accounts WHERE %l", $where);
$this->assert(count($result) === 1);
$this->assert($result[0]['age'] === '15');
}
@@ -17,7 +17,7 @@ class WhereClauseTest extends SimpleTest {
$subclause->add('age=%i', 15);
$subclause->add('age=%i', 14);
$result = DB::query("SELECT * FROM accounts WHERE %l", $where->text());
$result = DB::query("SELECT * FROM accounts WHERE %l", $where);
$this->assert(count($result) === 1);
$this->assert($result[0]['age'] === '15');
}
@@ -29,7 +29,7 @@ class WhereClauseTest extends SimpleTest {
$subclause->add('username!=%s', 'Bart');
$subclause->negateLast();
$result = DB::query("SELECT * FROM accounts WHERE %l", $where->text());
$result = DB::query("SELECT * FROM accounts WHERE %l", $where);
$this->assert(count($result) === 1);
$this->assert($result[0]['age'] === '15');
}