Merge pull request #1788 from udan11/lint

[WIP] Added lint.
This commit is contained in:
Marc Delisle 2015-07-18 17:54:40 -04:00
commit e4d4349024
30 changed files with 973 additions and 191 deletions

77
js/codemirror/addon/lint/lint.css vendored Normal file
View File

@ -0,0 +1,77 @@
/* The lint marker gutter */
.CodeMirror-lint-markers {
width: 16px;
}
.CodeMirror-lint-tooltip {
background-color: infobackground;
border: 1px solid black;
border-radius: 4px 4px 4px 4px;
color: infotext;
font-size: 10pt;
overflow: hidden;
padding: 2px 5px;
position: fixed;
white-space: pre;
white-space: pre-wrap;
z-index: 100;
max-width: 600px;
opacity: 0;
transition: opacity .4s;
-moz-transition: opacity .4s;
-webkit-transition: opacity .4s;
-o-transition: opacity .4s;
-ms-transition: opacity .4s;
}
.CodeMirror-lint-tooltip code {
font-family: monospace;
font-weight: bold;
}
.CodeMirror-lint-mark-error, .CodeMirror-lint-mark-warning {
background-position: left bottom;
background-repeat: repeat-x;
}
.CodeMirror-lint-mark-error {
background-image:
url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==")
;
}
.CodeMirror-lint-mark-warning {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=");
}
.CodeMirror-lint-marker-error, .CodeMirror-lint-marker-warning {
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
display: inline-block;
height: 16px;
width: 16px;
vertical-align: middle;
position: relative;
}
.CodeMirror-lint-message-error, .CodeMirror-lint-message-warning {
padding-left: 18px;
background-position: top left;
background-repeat: no-repeat;
}
.CodeMirror-lint-marker-error, .CodeMirror-lint-message-error {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=");
}
.CodeMirror-lint-marker-warning, .CodeMirror-lint-message-warning {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=");
}
.CodeMirror-lint-marker-multiple {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC");
background-repeat: no-repeat;
background-position: right bottom;
width: 100%; height: 100%;
}

209
js/codemirror/addon/lint/lint.js vendored Normal file
View File

@ -0,0 +1,209 @@
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: http://codemirror.net/LICENSE
(function(mod) {
if (typeof exports == "object" && typeof module == "object") // CommonJS
mod(require("../../lib/codemirror"));
else if (typeof define == "function" && define.amd) // AMD
define(["../../lib/codemirror"], mod);
else // Plain browser env
mod(CodeMirror);
})(function(CodeMirror) {
"use strict";
var GUTTER_ID = "CodeMirror-lint-markers";
function showTooltip(e, content) {
var tt = document.createElement("div");
tt.className = "CodeMirror-lint-tooltip";
tt.appendChild(content.cloneNode(true));
document.body.appendChild(tt);
function position(e) {
if (!tt.parentNode) return CodeMirror.off(document, "mousemove", position);
tt.style.top = Math.max(0, e.clientY - tt.offsetHeight - 5) + "px";
tt.style.left = (e.clientX + 5) + "px";
}
CodeMirror.on(document, "mousemove", position);
position(e);
if (tt.style.opacity != null) tt.style.opacity = 1;
return tt;
}
function rm(elt) {
if (elt.parentNode) elt.parentNode.removeChild(elt);
}
function hideTooltip(tt) {
if (!tt.parentNode) return;
if (tt.style.opacity == null) rm(tt);
tt.style.opacity = 0;
setTimeout(function() { rm(tt); }, 600);
}
function showTooltipFor(e, content, node) {
var tooltip = showTooltip(e, content);
function hide() {
CodeMirror.off(node, "mouseout", hide);
if (tooltip) { hideTooltip(tooltip); tooltip = null; }
}
var poll = setInterval(function() {
if (tooltip) for (var n = node;; n = n.parentNode) {
if (n && n.nodeType == 11) n = n.host;
if (n == document.body) return;
if (!n) { hide(); break; }
}
if (!tooltip) return clearInterval(poll);
}, 400);
CodeMirror.on(node, "mouseout", hide);
}
function LintState(cm, options, hasGutter) {
this.marked = [];
this.options = options;
this.timeout = null;
this.hasGutter = hasGutter;
this.onMouseOver = function(e) { onMouseOver(cm, e); };
}
function parseOptions(_cm, options) {
if (options instanceof Function) return {getAnnotations: options};
if (!options || options === true) options = {};
return options;
}
function clearMarks(cm) {
var state = cm.state.lint;
if (state.hasGutter) cm.clearGutter(GUTTER_ID);
for (var i = 0; i < state.marked.length; ++i)
state.marked[i].clear();
state.marked.length = 0;
}
function makeMarker(labels, severity, multiple, tooltips) {
var marker = document.createElement("div"), inner = marker;
marker.className = "CodeMirror-lint-marker-" + severity;
if (multiple) {
inner = marker.appendChild(document.createElement("div"));
inner.className = "CodeMirror-lint-marker-multiple";
}
if (tooltips != false) CodeMirror.on(inner, "mouseover", function(e) {
showTooltipFor(e, labels, inner);
});
return marker;
}
function getMaxSeverity(a, b) {
if (a == "error") return a;
else return b;
}
function groupByLine(annotations) {
var lines = [];
for (var i = 0; i < annotations.length; ++i) {
var ann = annotations[i], line = ann.from.line;
(lines[line] || (lines[line] = [])).push(ann);
}
return lines;
}
function annotationTooltip(ann) {
var severity = ann.severity;
if (!severity) severity = "error";
var tip = document.createElement("div");
tip.className = "CodeMirror-lint-message-" + severity;
tip.appendChild(document.createTextNode(ann.message));
// Unescaping only the <code> tag.
tip.innerHTML = tip.innerHTML.replace("&lt;code&gt;", "<code>")
.replace("&lt;/code&gt;", "</code>");
return tip;
}
function startLinting(cm) {
var state = cm.state.lint, options = state.options;
var passOptions = options.options || options; // Support deprecated passing of `options` property in options
var getAnnotations = options.getAnnotations || cm.getHelper(CodeMirror.Pos(0, 0), "lint");
if (!getAnnotations) return;
if (options.async || getAnnotations.async)
getAnnotations(cm.getValue(), updateLinting, passOptions, cm);
else
updateLinting(cm, getAnnotations(cm.getValue(), passOptions, cm));
}
function updateLinting(cm, annotationsNotSorted) {
clearMarks(cm);
var state = cm.state.lint, options = state.options;
var annotations = groupByLine(annotationsNotSorted);
for (var line = 0; line < annotations.length; ++line) {
var anns = annotations[line];
if (!anns) continue;
var maxSeverity = null;
var tipLabel = state.hasGutter && document.createDocumentFragment();
for (var i = 0; i < anns.length; ++i) {
var ann = anns[i];
var severity = ann.severity;
if (!severity) severity = "error";
maxSeverity = getMaxSeverity(maxSeverity, severity);
if (options.formatAnnotation) ann = options.formatAnnotation(ann);
if (state.hasGutter) tipLabel.appendChild(annotationTooltip(ann));
if (ann.to) state.marked.push(cm.markText(ann.from, ann.to, {
className: "CodeMirror-lint-mark-" + severity,
__annotation: ann
}));
}
if (state.hasGutter)
cm.setGutterMarker(line, GUTTER_ID, makeMarker(tipLabel, maxSeverity, anns.length > 1,
state.options.tooltips));
}
if (options.onUpdateLinting) options.onUpdateLinting(annotationsNotSorted, annotations, cm);
}
function onChange(cm) {
var state = cm.state.lint;
if (!state) return;
clearTimeout(state.timeout);
state.timeout = setTimeout(function(){startLinting(cm);}, state.options.delay || 500);
}
function popupSpanTooltip(ann, e) {
var target = e.target || e.srcElement;
showTooltipFor(e, annotationTooltip(ann), target);
}
function onMouseOver(cm, e) {
var target = e.target || e.srcElement;
if (!/\bCodeMirror-lint-mark-/.test(target.className)) return;
var box = target.getBoundingClientRect(), x = (box.left + box.right) / 2, y = (box.top + box.bottom) / 2;
var spans = cm.findMarksAt(cm.coordsChar({left: x, top: y}, "client"));
for (var i = 0; i < spans.length; ++i) {
var ann = spans[i].__annotation;
if (ann) return popupSpanTooltip(ann, e);
}
}
CodeMirror.defineOption("lint", false, function(cm, val, old) {
if (old && old != CodeMirror.Init) {
clearMarks(cm);
cm.off("change", onChange);
CodeMirror.off(cm.getWrapperElement(), "mouseover", cm.state.lint.onMouseOver);
clearTimeout(cm.state.lint.timeout);
delete cm.state.lint;
}
if (val) {
var gutters = cm.getOption("gutters"), hasLintGutter = false;
for (var i = 0; i < gutters.length; ++i) if (gutters[i] == GUTTER_ID) hasLintGutter = true;
var state = cm.state.lint = new LintState(cm, parseOptions(cm, val), hasLintGutter);
cm.on("change", onChange);
if (state.options.tooltips != false)
CodeMirror.on(cm.getWrapperElement(), "mouseover", state.onMouseOver);
startLinting(cm);
}
});
});

37
js/codemirror/addon/lint/sql-lint.js vendored Normal file
View File

@ -0,0 +1,37 @@
CodeMirror.sqlLint = function(text, updateLinting, options, cm) {
// Skipping check if text box is empty.
if(text.trim() == "") {
updateLinting(cm, []);
return;
}
function handleResponse(json) {
response = JSON.parse(json);
var found = [];
for (var idx in response) {
found.push({
from: CodeMirror.Pos(
response[idx].fromLine, response[idx].fromColumn
),
to: CodeMirror.Pos(
response[idx].toLine, response[idx].toColumn
),
message: response[idx].message,
severity : response[idx].severity
});
}
updateLinting(cm, found);
}
$.ajax({
method: "POST",
url: "lint.php",
data: {
'sql_query': text
},
success: handleResponse
});
}

View File

@ -112,6 +112,17 @@ function PMA_getSQLEditor($textarea, options, resize) {
mode: "text/x-mysql",
lineWrapping: true
};
if (CodeMirror.sqlLint) {
$.extend(defaults, {
gutters: ["CodeMirror-lint-markers"],
lint: {
"getAnnotations": CodeMirror.sqlLint,
"async": true,
}
});
}
$.extend(true, defaults, options);
// create CodeMirror editor

View File

@ -407,6 +407,10 @@ class PMA_Header
$this->_scripts->addFile('codemirror/addon/runmode/runmode.js');
$this->_scripts->addFile('codemirror/addon/hint/show-hint.js');
$this->_scripts->addFile('codemirror/addon/hint/sql-hint.js');
if ($GLOBALS['cfg']['LintEnable']) {
$this->_scripts->addFile('codemirror/addon/lint/lint.js');
$this->_scripts->addFile('codemirror/addon/lint/sql-lint.js');
}
}
$this->_scripts->addCode(
'ConsoleEnterExecutes='
@ -658,6 +662,8 @@ class PMA_Header
. $basedir . 'js/codemirror/lib/codemirror.css" />';
$retval .= '<link rel="stylesheet" type="text/css" href="'
. $basedir . 'js/codemirror/addon/hint/show-hint.css" />';
$retval .= '<link rel="stylesheet" type="text/css" href="'
. $basedir . 'js/codemirror/addon/lint/lint.css" />';
$retval .= '<link rel="stylesheet" type="text/css" href="'
. $basedir . 'phpmyadmin.css.php?'
. 'nocache=' . $theme_id . $GLOBALS['text_dir'] . '" />';

162
libraries/Linter.class.php Normal file
View File

@ -0,0 +1,162 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Analyzes a query and gives user feedback.
*
* @package PhpMyAdmin
*/
if (! defined('PHPMYADMIN')) {
exit;
}
/**
* The linter itself.
*
* @package PhpMyAdmin
*/
class PMA_Linter
{
/**
* Gets the starting position of each line.
*
* @param string $str String to be analyzed.
*
* @return array
*/
public static function getLines($str)
{
$lines = array(0);
// The reason for using the '8bit' parameter is that the length
// required is the length in bytes, not characters.
//
// Given the following string: `????+`, where `?` represents a
// multi-byte character (lets assume that every `?` is a 2-byte
// character) and `+` is a newline, the first value of `$i` is `0` and
// the last one is `4` (because there are 5 characters). Bytes `$str[0]`
// and `$str[1]` are the first character, `$str[2]` and `$str[3]` are
// the second one and `$str[4]` is going to be the first byte of the
// third character. The fourth and the last one (which is actually a new
// line) aren't going to be processed at all.
for ($i = 0, $len = /*overload*/mb_strlen($str, '8bit'); $i < $len; ++$i) {
if ($str[$i] === "\n") {
$lines[] = $i + 1;
}
}
return $lines;
}
/**
* Computes the number of the line and column given an absolute position.
*
* @param array $lines The starting position of each line.
* @param int $pos The absolute position
*
* @return void
*/
public static function findLineNumberAndColumn($lines, $pos)
{
$line = 0;
foreach ($lines as $lineNo => $lineStart) {
if ($lineStart > $pos) {
break;
}
$line = $lineNo;
}
return array($line, $pos - $lines[$line]);
}
/**
* Runs the linting process.
*
* @param string $query The query to be checked.
*
* @return void
*/
public static function lint($query)
{
// Disabling lint for huge queries to save some resources.
if (/*overload*/mb_strlen($query) > 10000) {
echo json_encode(
array(
array(
'message' => 'The linting is disabled for this query because it exceededs the maxmimum length.',
'fromLine' => 0,
'fromColumn' => 0,
'toLine' => 0,
'toColumn' => 0,
'severity' => 'warning',
)
)
);
return;
}
/**
* Lexer used for tokenizing the query.
*
* @var SqlParser\Lexer
*/
$lexer = new SqlParser\Lexer($query);
/**
* Parsed used for analysing the query.
*
* @var SqlParser\Parser
*/
$parser = new SqlParser\Parser($lexer->list);
/**
* Array containing all errors.
*
* @var array
*/
$errors = SqlParser\Utils\Error::get(array($lexer, $parser));
/**
* The response containing of all errors.
*
* @var array
*/
$response = array();
/**
* The starting position for each line.
*
* CodeMirror requires relative position to line, but the parser stores
* only the absolute position of the character in string.
*
* @var array
*/
$lines = static::getLines($query);
// Building the response.
foreach ($errors as $idx => $error) {
// Starting position of the string that caused the error.
list($fromLine, $fromColumn) = static::findLineNumberAndColumn(
$lines, $error[3]
);
// Ending position of the string that caused the error.
list($toLine, $toColumn) = static::findLineNumberAndColumn(
$lines, $error[3] + /*overload*/mb_strlen($error[2])
);
// Building the response.
$response[] = array(
'message' => $error[0] . ' (near <code>' . $error[2] . '</code>)',
'fromLine' => $fromLine,
'fromColumn' => $fromColumn,
'toLine' => $toLine,
'toColumn' => $toColumn,
'severity' => 'error',
);
}
// Sending back the answer.
echo json_encode($response);
}
}

View File

@ -757,6 +757,13 @@ $cfg['RetainQueryBox'] = false;
*/
$cfg['CodemirrorEnable'] = true;
/**
* use the parser to find any errors in the query before executing
*
* @global boolean $cfg['LintEnable']
*/
$cfg['LintEnable'] = true;
/**
* show a 'Drop database' link to normal users
*

View File

@ -57,6 +57,9 @@ $strConfigCodemirrorEnable_desc = __(
. 'line numbers.'
);
$strConfigCodemirrorEnable_name = __('Enable CodeMirror');
$strConfigLintEnable_desc = __('Find any erorors in the query before executing it.'
. 'Requires CodeMirror to be enabled.');
$strConfigLintEnable_name = __('Enable linter');
$strConfigMinSizeForInputField_desc = __(
'Defines the minimum size for input fields generated for CHAR and VARCHAR '
. 'columns.'

View File

@ -162,6 +162,7 @@ $forms['Sql_queries']['Sql_queries'] = array(
'MaxCharactersInDisplayedSQL',
'RetainQueryBox',
'CodemirrorEnable',
'LintEnable',
'EnableAutocompleteForTablesAndColumns',
'DefaultForeignKeyChecks');
$forms['Sql_queries']['Sql_box'] = array('SQLQuery' => array(

View File

@ -70,6 +70,7 @@ $forms['Sql_queries']['Sql_queries'] = array(
'MaxCharactersInDisplayedSQL',
'RetainQueryBox',
'CodemirrorEnable',
'LintEnable',
'EnableAutocompleteForTablesAndColumns',
'DefaultForeignKeyChecks');
$forms['Sql_queries']['Sql_box'] = array(

View File

@ -139,12 +139,19 @@ class AlterOperation extends Component
break;
}
// Skipping whitespaces and comments.
if (($token->type === Token::TYPE_WHITESPACE) || ($token->type === Token::TYPE_COMMENT)) {
if ($state !== 2) {
// State 2 parses the unknown part which must include whitespaces as well.
continue;
// Skipping comments.
if ($token->type === Token::TYPE_COMMENT) {
continue;
}
// Skipping whitespaces.
if ($token->type === Token::TYPE_WHITESPACE) {
if ($state === 2) {
// When parsing the unknown part, the whitespaces are
// included to not break anything.
$ret->unknown[] = $token;
}
continue;
}
if ($state === 0) {
@ -179,6 +186,11 @@ class AlterOperation extends Component
}
}
if ($ret->options->isEmpty()) {
$parser->error('Unrecognized alter operation.', $list->tokens[$list->idx]);
return null;
}
--$list->idx;
return $ret;
}

View File

@ -25,40 +25,39 @@ use SqlParser\TokensList;
class Array2d extends Component
{
/**
* An array with the values of the row to be inserted.
*
* @var array
*/
public $values;
/**
* @param Parser $parser The parser that serves as context.
* @param TokensList $list The list of tokens that are being parsed.
* @param array $options Parameters for parsing.
*
* @return Array2d
* @return ArrayObj[]
*/
public static function parse(Parser $parser, TokensList $list, array $options = array())
{
$ret = array();
$expr = new Array2d();
$value = '';
/**
* Whether an array was parsed or not. To be a valid parsing, at least
* one array must be parsed after each comma.
* @var bool $parsed
*/
$parsed = false;
/**
* The number of values in each set.
* @var int
*/
$count = -1;
/**
* The state of the parser.
*
* Below are the states of the parser.
*
* 0 ------------------------[ ( ]-----------------------> 1
* 0 ----------------------[ array ]---------------------> 1
*
* 1 ----------------------[ value ]---------------------> 2
*
* 2 ------------------------[ , ]-----------------------> 1
* 2 ------------------------[ ) ]-----------------------> 3
*
* 3 ---------------------[ options ]--------------------> 4
* 1 ------------------------[ , ]------------------------> 0
* 1 -----------------------[ else ]----------------------> -1
*
* @var int
*/
@ -86,40 +85,36 @@ class Array2d extends Component
break;
}
if ($token->type === Token::TYPE_OPERATOR) {
if ($state === 0) {
if ($token->value === '(') {
$state = 1;
continue;
} elseif ($token->value === ',') {
if ($state !== 3) {
$expr->values[] = $value;
$value = '';
$state = 1;
$arr = ArrayObj::parse($parser, $list, $options);
$arrCount = count($arr->values);
if ($count === -1) {
$count = $arrCount;
} elseif ($arrCount != $count) {
$parser->error("{$count} values were expected, but found {$arrCount}.", $token);
}
continue;
} elseif ($token->value === ')') {
$state = 3;
$expr->values[] = $value;
$ret[] = $expr;
$value = '';
$expr = new Array2d();
continue;
$ret[] = $arr;
$parsed = true;
$state = 1;
} else {
break;
}
} elseif ($state === 1) {
if ($token->value === ',') {
$parsed = false;
$state = 0;
} else {
break;
}
// No other operator is expected.
break;
}
if ($state === 1) {
$value .= $token->value;
$state = 2;
}
}
// Last iteration was not saved.
if (!empty($expr->values)) {
$ret[] = $expr;
if (!$parsed) {
$parser->error(
'An opening bracket followed by a set of values was expected.',
$list->tokens[$list->idx]
);
}
--$list->idx;

View File

@ -97,7 +97,7 @@ class ArrayObj extends Component
if ($state === 0) {
if (($token->type !== Token::TYPE_OPERATOR) || ($token->value !== '(')) {
$parser->error('An open bracket was expected.', $token);
$parser->error('An opening bracket was expected.', $token);
break;
}
$state = 1;
@ -111,7 +111,7 @@ class ArrayObj extends Component
$state = 2;
} elseif ($state === 2) {
if (($token->type !== Token::TYPE_OPERATOR) || (($token->value !== ',') && ($token->value !== ')'))) {
$parser->error('Symbols \')\' or \',\' were expected', $token);
$parser->error('A comma or a closing bracket was expected', $token);
break;
}
if ($token->value === ',') {

View File

@ -126,9 +126,9 @@ class Expression extends Component
/**
* Whether a period was previously found.
* @var bool $period
* @var bool $dot
*/
$period = false;
$dot = false;
/**
* Whether an alias is expected. Is 2 if `AS` keyword was found.
@ -146,7 +146,7 @@ class Expression extends Component
* Keeps track of the previous token.
* Possible values:
* string, if function was previously found;
* true, if open bracket was previously found;
* true, if opening bracket was previously found;
* null, in any other case.
* @var string|bool $prev
*/
@ -169,7 +169,7 @@ class Expression extends Component
if (($isExpr) && (!$alias)) {
$ret->expr .= $token->token;
}
if (($alias === 0) && (empty($options['noAlias'])) && (!$isExpr) && (!$period) && (!empty($ret->expr))) {
if (($alias === 0) && (empty($options['noAlias'])) && (!$isExpr) && (!$dot) && (!empty($ret->expr))) {
$alias = 1;
}
continue;
@ -202,9 +202,6 @@ class Expression extends Component
}
if ($token->value === '(') {
++$brackets;
// We don't check to see if `$prev` is `true` (open bracket
// was found before) because the brackets count is one (the
// only bracket we found is this one).
if ((empty($ret->function)) && ($prev !== null) && ($prev !== true)) {
// A function name was previously found and now an open
// bracket, so this is a function call.
@ -222,7 +219,7 @@ class Expression extends Component
break;
}
} elseif ($brackets < 0) {
$parser->error('Unexpected bracket.', $token);
$parser->error('Unexpected closing bracket.', $token);
$brackets = 0;
}
} elseif ($token->value === ',') {
@ -250,28 +247,36 @@ class Expression extends Component
// Found a `.` which means we expect a column name and
// the column name we parsed is actually the table name
// and the table name is actually a database name.
if ((!empty($ret->database)) || ($period)) {
if ((!empty($ret->database)) || ($dot)) {
$parser->error('Unexpected dot.', $token);
}
$ret->database = $ret->table;
$ret->table = $ret->column;
$ret->column = null;
$period = true;
$dot = true;
} else {
// We found the name of a column (or table if column
// field should be skipped; used to parse table names).
if (!empty($options['skipColumn'])) {
if (!empty($ret->table)) {
$field = (!empty($options['skipColumn'])) ? 'table' : 'column';
if (!empty($ret->$field)) {
// No alias is expected.
if (!empty($options['noAlias'])) {
break;
}
$ret->table = $token->value;
// Parsing aliases without `AS` keyword and any whitespace.
// Example: SELECT 1`foo`
if (($token->type === Token::TYPE_STRING)
|| (($token->type === Token::TYPE_SYMBOL)
&& ($token->flags & Token::FLAG_SYMBOL_BACKTICK))
) {
$ret->alias = $token->value;
}
} else {
if (!empty($ret->column)) {
break;
}
$ret->column = $token->value;
$ret->$field = $token->value;
}
$period = false;
$dot = false;
}
} else {
// Parsing aliases without `AS` keyword.
@ -298,9 +303,8 @@ class Expression extends Component
}
}
if ($alias === 2) {
$parser->error('Alias was expected.');
$parser->error('An alias was expected.', $list->tokens[$list->idx - 1]);
}
// Whitespaces might be added at the end.

View File

@ -36,6 +36,11 @@ class FieldDefinition extends Component
* @var array
*/
public static $FIELD_OPTIONS = array(
// Tells the `OptionsArray` to not sort the options.
// See the note below.
'_UNSORTED' => true,
'NOT NULL' => 1,
'NULL' => 1,
'DEFAULT' => array(2, 'var'),
@ -48,13 +53,26 @@ class FieldDefinition extends Component
'COLUMN_FORMAT' => array(6, 'var'),
'ON UPDATE' => array(7, 'var'),
// MariaDB options.
'GENERATED ALWAYS' => 1,
'AS' => array(2, 'expr', array('bracketsDelimited' => true)),
'VIRTUAL' => 3,
'PERSISTENT' => 3,
// 'UNIQUE' => 4, // common
// 'UNIQUE KEY' => 4, // common
// Generated columns options.
'GENERATED ALWAYS' => 8,
'AS' => array(9, 'expr', array('bracketsDelimited' => true)),
'VIRTUAL' => 10,
'PERSISTENT' => 11,
'STORED' => 11,
// Common entries.
//
// NOTE: Some of the common options are not in the same order which
// causes troubles when checking if the options are in the right order.
// I should find a way to define multiple sets of options and make the
// parser select the right set.
//
// 'UNIQUE' => 4,
// 'UNIQUE KEY' => 4,
// 'COMMENT' => array(5, 'var'),
// 'NOT NULL' => 1,
// 'NULL' => 1,
// 'PRIMARY' => 4,
// 'PRIMARY KEY' => 4,
);
/**
@ -153,7 +171,7 @@ class FieldDefinition extends Component
* 4 --------------------[ REFERENCES ]------------------> 4
*
* 5 ------------------------[ , ]-----------------------> 1
* 5 ------------------------[ ) ]-----------------------> -1
* 5 ------------------------[ ) ]-----------------------> 6 (-1)
*
* @var int
*/
@ -179,6 +197,9 @@ class FieldDefinition extends Component
if ($state === 0) {
if (($token->type === Token::TYPE_OPERATOR) && ($token->value === '(')) {
$state = 1;
} else {
$parser->error('An opening bracket was expected.', $token);
break;
}
} elseif ($state === 1) {
if (($token->type === Token::TYPE_KEYWORD) && ($token->value === 'CONSTRAINT')) {
@ -213,13 +234,12 @@ class FieldDefinition extends Component
$expr = new FieldDefinition();
if ($token->value === ',') {
$state = 1;
continue;
} elseif ($token->value === ')') {
$state = 6;
++$list->idx;
break;
}
}
}
// Last iteration was not saved.
@ -227,12 +247,16 @@ class FieldDefinition extends Component
$ret[] = $expr;
}
if (($state !== 0) && ($state !== 6)) {
$parser->error('A closing bracket was expected.', $list->tokens[$list->idx - 1]);
}
--$list->idx;
return $ret;
}
/**
* @param FieldDefinition[] $component The component to be built.
* @param FieldDefinition|FieldDefinition[] $component The component to be built.
*
* @return string
*/

View File

@ -87,7 +87,7 @@ class Limit extends Component
if (($token->type === Token::TYPE_KEYWORD) && ($token->value === 'OFFSET')) {
if ($offset) {
$parser->error('An offset was expected.');
$parser->error('An offset was expected.', $token);
}
$offset = true;
continue;
@ -108,7 +108,7 @@ class Limit extends Component
}
if ($offset) {
$parser->error('An offset was expected.');
$parser->error('An offset was expected.', $list->tokens[$list->idx - 1]);
}
--$list->idx;

View File

@ -36,7 +36,8 @@ class OptionsArray extends Component
* Constructor.
*
* @param array $options The array of options. Options that have a value
* must be an array with two keys 'name' and 'value'.
* must be an array with at least two keys `name` and
* `expr` or `value`.
*/
public function __construct(array $options = array())
{
@ -162,11 +163,11 @@ class OptionsArray extends Component
'name' => $token->value,
// @var bool Whether it contains an equal sign.
// This is used by the builder to rebuild it.
'equal' => $lastOption[1] === 'var=',
'equals' => $lastOption[1] === 'var=',
// @var string Raw value.
'value' => '',
'expr' => '',
// @var string Processed value.
'value_' => '',
'value' => '',
);
$state = 1;
} elseif ($lastOption[1] === 'expr') {
@ -179,14 +180,14 @@ class OptionsArray extends Component
// @var string The name of the option.
'name' => $token->value,
// @var Expression The parsed expression.
'value' => null,
'expr' => null,
);
$state = 1;
}
} elseif ($state === 1) {
$state = 2;
if ($token->value === '=') {
$ret->options[$lastOptionId]['equal'] = true;
$ret->options[$lastOptionId]['equals'] = true;
continue;
}
}
@ -195,11 +196,13 @@ class OptionsArray extends Component
// change this iteration.
if ($state === 2) {
if ($lastOption[1] === 'expr') {
$ret->options[$lastOptionId]['value'] = Expression::parse(
$ret->options[$lastOptionId]['expr'] = Expression::parse(
$parser,
$list,
empty($lastOption[2]) ? array() : $lastOption[2]
);
$ret->options[$lastOptionId]['value']
= $ret->options[$lastOptionId]['expr']->expr;
$lastOption = null;
$state = 0;
} else {
@ -209,11 +212,14 @@ class OptionsArray extends Component
--$brackets;
}
// Raw value.
$ret->options[$lastOptionId]['value'] .= $token->token;
$ret->options[$lastOptionId]['expr'] .= $token->token;
// Processed value.
$ret->options[$lastOptionId]['value_'] .= $token->value;
if (!((($token->token === '(') && ($brackets === 1))
|| (($token->token === ')') && ($brackets === 0)))
) {
// First pair of brackets is being skipped.
$ret->options[$lastOptionId]['value'] .= $token->value;
}
// Checking if we finished parsing.
if ($brackets === 0) {
@ -223,7 +229,9 @@ class OptionsArray extends Component
}
}
ksort($ret->options);
if (empty($options['_UNSORTED'])) {
ksort($ret->options);
}
--$list->idx;
return $ret;
@ -245,9 +253,9 @@ class OptionsArray extends Component
$options[] = $option;
} else {
$options[] = $option['name']
. (!empty($option['equal']) ? '=' : ' ')
. ((string) $option['value']);
// If `$option['value']` happens to be a component, the magic
. (!empty($option['equals']) ? '=' : ' ')
. (!empty($option['expr']) ? ((string) $option['expr']) : $option['value']);
// If `$option['expr']` happens to be a component, the magic
// method will build it automatically.
}
}
@ -257,17 +265,19 @@ class OptionsArray extends Component
/**
* Checks if it has the specified option and returns it value or true.
*
* @param string $key The key to be checked.
* @param string $key The key to be checked.
* @param bool $getExpr Gets the expression instead of the value.
* The value is the processed form of the expression.
*
* @return mixed
*/
public function has($key)
public function has($key, $getExpr = false)
{
foreach ($this->options as $option) {
if ($key === $option) {
return true;
} elseif ((is_array($option)) && ($key === $option['name'])) {
return $option['value'];
return $getExpr ? $option['expr'] : $option['value'];
}
}
return false;
@ -309,4 +319,14 @@ class OptionsArray extends Component
$this->options = array_merge_recursive($this->options, $options->options);
}
}
/**
* Checks tf there are no options set.
*
* @return bool
*/
public function isEmpty()
{
return empty($this->options);
}
}

View File

@ -120,7 +120,6 @@ class ParameterDefinition extends Component
$expr = new ParameterDefinition();
if ($token->value === ',') {
$state = 1;
continue;
} elseif ($token->value === ')') {
++$list->idx;
break;

View File

@ -52,6 +52,13 @@ class RenameOperation extends Component
$expr = new RenameOperation();
/**
* Whether an operation was parsed or not. To be a valid parsing, at
* least one operation must be parsed after each comma.
* @var bool $parsed
*/
$parsed = false;
/**
* The state of the parser.
*
@ -87,29 +94,7 @@ class RenameOperation extends Component
continue;
}
if (($token->type === Token::TYPE_KEYWORD) && ($token->flags & Token::FLAG_KEYWORD_RESERVED)) {
if (($state === 1) && ($token->value === 'TO')) {
$state = 2;
continue;
}
// No other keyword is expected.
break;
}
if ($token->type === Token::TYPE_OPERATOR) {
if (($state === 3) && ($token->value === ',')) {
$ret[] = $expr;
$expr = new RenameOperation();
$state = 0;
continue;
}
// No other operator is expected.
break;
}
if ($state == 0) {
if ($state === 0) {
$expr->old = Expression::parse(
$parser,
$list,
@ -119,8 +104,18 @@ class RenameOperation extends Component
'skipColumn' => true,
)
);
if (empty($expr->old)) {
$parser->error('The old name of the table was expected.', $token);
}
$state = 1;
} elseif ($state == 2) {
} elseif ($state === 1) {
if (($token->type === Token::TYPE_KEYWORD) && ($token->value === 'TO')) {
$state = 2;
} else {
$parser->error('Keyword "TO" was expected.', $token);
break;
}
} elseif ($state === 2) {
$expr->new = Expression::parse(
$parser,
$list,
@ -130,9 +125,26 @@ class RenameOperation extends Component
'noAlias' => true,
)
);
if (empty($expr->new)) {
$parser->error('The new name of the table was expected.', $token);
}
$state = 3;
$parsed = true;
} elseif ($state === 3) {
if (($token->type === Token::TYPE_OPERATOR) && ($token->value === ',')) {
$ret[] = $expr;
$expr = new RenameOperation();
$state = 0;
// Found a comma, looking for another operation.
$parsed = false;
} else {
break;
}
}
}
if (!$parsed) {
$parser->error('A rename operation was expected.', $list->tokens[$list->idx - 1]);
}
// Last iteration was not saved.

View File

@ -88,25 +88,23 @@ class SetOperation extends Component
break;
}
if ($token->type === Token::TYPE_OPERATOR) {
if ($token->value === ',') {
if ($state === 0) {
if ($token->token === '=') {
$state = 1;
} else {
$expr->column .= $token->token;
}
} elseif ($state === 1) {
if ($token->token === ',') {
$expr->column = trim($expr->column);
$expr->value = trim($expr->value);
$ret[] = $expr;
$expr = new SetOperation();
$state = 0;
continue;
} elseif ($token->value === '=') {
$state = 1;
continue;
} else {
$expr->value .= $token->token;
}
}
if ($state === 0) {
$expr->column .= $token->token;
} else { // } else if ($state === 1) {
$expr->value .= $token->token;
}
}
// Last iteration was not saved.

View File

@ -397,7 +397,9 @@ abstract class Context
*/
public static function isSeparator($str)
{
return !ctype_alnum($str) && $str !== '_';
// NOTES: Only ASCII characters may be separators.
// `~` is the last printable ASCII character.
return ($str <= '~') && (!ctype_alnum($str)) && ($str !== '_');
}
/**
@ -434,7 +436,7 @@ abstract class Context
* Loads the context with the closest version to the one specified.
*
* The closest context is found by replacing last digits with zero until one
* is loaded succesfully.
* is loaded successfully.
*
* @see Context::load()
*
@ -448,13 +450,12 @@ abstract class Context
/**
* The number of replaces done by `preg_replace`.
* This actually represents whether a new context was generated or not.
* @var int
* @var int $count
*/
$count = 0;
// As long as a new context can be generated, we try to laod it.
// As long as a new context can be generated, we try to load it.
do {
$loaded = true;
try {
// Trying to load the new context.
static::load($context);

View File

@ -13,6 +13,23 @@ namespace SqlParser;
use SqlParser\Exceptions\LexerException;
if (!defined('USE_UTF_STRINGS')) {
/**
* Forces usage of `UtfString` if the string is multibyte.
* `UtfString` may be slower, but it gives better results.
* @var bool
*/
define('USE_UTF_STRINGS', true);
}
// Set internal character to UTF-8.
// In previous versions of PHP (5.5 and older) the default internal encoding is
// "ISO-8859-1".
if ((defined('USE_UTF_STRINGS')) && (USE_UTF_STRINGS)) {
mb_internal_encoding('UTF-8');
}
/**
* Performs lexical analysis over a SQL statement and splits it in multiple
* tokens.
@ -149,11 +166,24 @@ class Lexer
*/
public function __construct($str, $strict = false)
{
// `strlen` is used instead of `mb_strlen` because the lexer needs to
// parse each byte of the input.
$len = ($str instanceof UtfString) ? $str->length() : strlen($str);
// For multi-byte strings, a new instance of `UtfString` is
// initialized (only if `UtfString` usage is forced.
if (!($str instanceof UtfString)) {
if ((USE_UTF_STRINGS) && ($len != mb_strlen($str))) {
$str = new UtfString($str);
}
}
$this->str = $str;
$this->len = ($str instanceof UtfString) ?
$str->length() : strlen($str);
$this->len = ($str instanceof UtfString) ? $str->length() : $len;
$this->strict = $strict;
// Setting the delimiter.
$this->delimiter = static::$DEFAULT_DELIMITER;
$this->lex();

View File

@ -338,7 +338,7 @@ class Parser
// Checking if it is a known statement that can be parsed.
if (empty(static::$STATEMENT_PARSERS[$token->value])) {
$this->error(
'Unrecognized statement type "' . $token->value . '".',
'Unrecognized statement type.',
$token
);
// Skipping to the end of this statement.
@ -376,6 +376,10 @@ class Parser
&& ($lastStatement instanceof SelectStatement)
&& ($stmt instanceof SelectStatement)
) {
/**
* Last SELECT statement.
* @var SelectStatement $lastStatement
*/
$lastStatement->union[] = $stmt;
$inUnion = false;
} else {

View File

@ -244,7 +244,7 @@ abstract class Statement
// There is no parser for this keyword and isn't the beginning
// of a statement (so no options) either.
$parser->error(
'Unrecognized keyword "' . $token->value . '".',
'Unrecognized keyword.',
$token
);
continue;
@ -300,8 +300,8 @@ abstract class Statement
*
* @return string
*/
public function __toString()
public function __toString()
{
return static::build($this);
return $this->build();
}
}

View File

@ -287,7 +287,15 @@ class CreateStatement extends Statement
'skipColumn' => true,
)
);
++$list->idx; // Skipping field.
if (empty($this->name)) {
$parser->error(
'The name of the entity was expected.',
$list->tokens[$list->idx]
);
} else {
++$list->idx; // Skipping field.
}
if ($this->options->has('DATABASE')) {
$this->entityOptions = OptionsArray::parse(
@ -297,6 +305,12 @@ class CreateStatement extends Statement
);
} elseif ($this->options->has('TABLE')) {
$this->fields = FieldDefinition::parse($parser, $list);
if (empty($this->fields)) {
$parser->error(
'At least one field definition was expected.',
$list->tokens[$list->idx]
);
}
++$list->idx;
$this->entityOptions = OptionsArray::parse(
@ -312,7 +326,7 @@ class CreateStatement extends Statement
$token = $list->getNextOfType(Token::TYPE_KEYWORD);
if ($token->value !== 'RETURNS') {
$parser->error(
'\'RETURNS\' keyword was expected.',
'A \'RETURNS\' keyword was expected.',
$token
);
} else {

View File

@ -93,7 +93,7 @@ class UtfString implements \ArrayAccess
*/
public function offsetExists($offset)
{
return $offset < $this->charLen;
return ($offset >= 0) && ($offset < $this->charLen);
}
/**
@ -190,25 +190,12 @@ class UtfString implements \ArrayAccess
return 3;
} elseif ($byte < 248) {
return 4;
} elseif ($byte === 252) {
} elseif ($byte < 252) {
return 5; // unofficial
}
return 6; // unofficial
}
/**
* Returns the number of remaining characters.
*
* @return int
*/
public function remaining()
{
if ($this->charIdx < $this->charLen) {
return $this->charLen - $this->charIdx;
}
return 0;
}
/**
* Returns the length in characters of the string.
*
@ -220,30 +207,12 @@ class UtfString implements \ArrayAccess
}
/**
* Gets the values of the indexes.
* Returns the contained string.
*
* @param int &$byte Reference to the byte index.
* @param int &$char Reference to the character index.
*
* @return void
* @return strin
*/
public function getIndexes(&$byte, &$char)
public function __toString()
{
$byte = $this->byteIdx;
$char = $this->charIdx;
}
/**
* Sets the values of the indexes.
*
* @param int $byte The byte index.
* @param int $char The character index.
*
* @return void
*/
public function setIndexes($byte = 0, $char = 0)
{
$this->byteIdx = $byte;
$this->charIdx = $char;
return $this->str;
}
}

View File

@ -8,6 +8,7 @@
*/
namespace SqlParser\Utils;
use SqlParser\Components\Expression;
use SqlParser\Statements\SelectStatement;
/**

View File

@ -125,7 +125,7 @@ class Table
if (($option = $field->options->has('AS'))) {
$ret[$field->name]['generated'] = true;
$ret[$field->name]['expr'] = $option->expr;
$ret[$field->name]['expr'] = $option;
}
}

38
lint.php Normal file
View File

@ -0,0 +1,38 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Represents the interface between the linter and the query editor.
*
* @package PhpMyAdmin
*/
define('PHPMYADMIN', true);
// We load the minimum files required to check if the user is logged in.
require_once 'libraries/core.lib.php';
require_once 'libraries/Config.class.php';
$GLOBALS['PMA_Config'] = new PMA_Config(CONFIG_FILE);
require_once 'libraries/session.inc.php';
// If user is not logged in, he should not send any requests, so we exit here to
// avoid external requests.
if (empty($_SESSION['encryption_key'])) {
// Unauthorized access detected.
exit;
}
/**
* Loads the SQL lexer and parser, which are used to detect errors.
*/
require_once 'libraries/sql-parser/autoload.php';
/**
* Loads the linter.
*/
require_once 'libraries/Linter.class.php';
// The input of this function does not need to be checked again XSS or MySQL
// injections because it is never executed, just parsed.
// The client, which will recieve the JSON response will decode the message and
// and any HTML fragments that are displayed to the user will be encoded anyway.
PMA_Linter::lint($_REQUEST['sql_query']);

View File

@ -0,0 +1,147 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Tests for Linter.class.php.
*
* @package PhpMyAdmin-test
*/
/*
* Include to test.
*/
require_once 'libraries/Linter.class.php';
/**
* Tests for Linter.class.php.
*
* @package PhpMyAdmin-test
*/
class PMA_Linter_Test extends PHPUnit_Framework_TestCase
{
/**
* Test for PMA_Linter::getLines
*
* @return void
*/
public function testGetLines()
{
$this->assertEquals(array(0), PMA_Linter::getLines(''));
$this->assertEquals(array(0, 2), PMA_Linter::getLines("a\nb"));
$this->assertEquals(array(0, 4, 7), PMA_Linter::getLines("abc\nde\n"));
}
/**
* Test for PMA_Linter::findLineNumberAndColumn
*
* @return void
*/
public function testFindLineNumberAndColumn()
{
// Let the analyzed string be:
// ^abc$
// ^de$
// ^$
//
// Where `^` is the beginning of the line and `$` the end of the line.
//
// Positions of each character (by line):
// ( a, 0), ( b, 1), ( c, 2), (\n, 3),
// ( d, 4), ( e, 5), (\n, 6),
// (\n, 7).
$this->assertEquals(
array(1, 0),
PMA_Linter::findLineNumberAndColumn(array(0, 4, 7), 4)
);
$this->assertEquals(
array(1, 1),
PMA_Linter::findLineNumberAndColumn(array(0, 4, 7), 5)
);
$this->assertEquals(
array(1, 2),
PMA_Linter::findLineNumberAndColumn(array(0, 4, 7), 6)
);
$this->assertEquals(
array(2, 0),
PMA_Linter::findLineNumberAndColumn(array(0, 4, 7), 7)
);
}
/**
* Test for PMA_Linter::lint
*
* @return void
*/
public function testLintEmpty()
{
$this->expectOutputString('[]');
PMA_Linter::lint('');
}
/**
* Test for PMA_Linter::lint
*
* @return void
*/
public function testLintNoErrors()
{
$this->expectOutputString('[]');
PMA_Linter::lint('SELECT * FROM tbl');
}
/**
* Test for PMA_Linter::lint
*
* @return void
*/
public function testLintErrors()
{
$this->expectOutputString(
json_encode(
array(
array(
'message' => 'Unrecognized data type. (near <code>IN</code>)',
'fromLine' => 0,
'fromColumn' => 22,
'toLine' => 0,
'toColumn' => 24,
'severity' => 'error',
),
array(
'message' => 'A closing bracket was expected. (near <code>IN</code>)',
'fromLine' => 0,
'fromColumn' => 22,
'toLine' => 0,
'toColumn' => 24,
'severity' => 'error',
)
)
)
);
PMA_Linter::lint('CREATE TABLE tbl ( id IN');
}
/**
* Test for PMA_Linter::lint
*
* @return void
*/
public function testLongQuery()
{
$this->expectOutputString(
json_encode(
array(
array(
'message' => 'The linting is disabled for this query because it exceededs the maxmimum length.',
'fromLine' => 0,
'fromColumn' => 0,
'toLine' => 0,
'toColumn' => 0,
'severity' => 'warning',
)
)
)
);
PMA_Linter::lint(str_repeat(";", 10001));
}
}