diff options
30 files changed, 973 insertions, 191 deletions
diff --git a/js/codemirror/addon/lint/lint.css b/js/codemirror/addon/lint/lint.css new file mode 100644 index 0000000000..ca59a6f1ff --- /dev/null +++ b/js/codemirror/addon/lint/lint.css @@ -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%; +} diff --git a/js/codemirror/addon/lint/lint.js b/js/codemirror/addon/lint/lint.js new file mode 100644 index 0000000000..fc4a636320 --- /dev/null +++ b/js/codemirror/addon/lint/lint.js @@ -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("<code>", "<code>") + .replace("</code>", "</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); + } + }); +}); diff --git a/js/codemirror/addon/lint/sql-lint.js b/js/codemirror/addon/lint/sql-lint.js new file mode 100644 index 0000000000..557d3a478c --- /dev/null +++ b/js/codemirror/addon/lint/sql-lint.js @@ -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 + }); +} diff --git a/js/functions.js b/js/functions.js index 531994079a..b889eeedee 100644 --- a/js/functions.js +++ b/js/functions.js @@ -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 diff --git a/libraries/Header.class.php b/libraries/Header.class.php index f138679be1..00da50a661 100644 --- a/libraries/Header.class.php +++ b/libraries/Header.class.php @@ -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=' @@ -659,6 +663,8 @@ class PMA_Header $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'] . '" />'; // load Print view's CSS last, so that it overrides all other CSS while 'printing' diff --git a/libraries/Linter.class.php b/libraries/Linter.class.php new file mode 100644 index 0000000000..5315ce479f --- /dev/null +++ b/libraries/Linter.class.php @@ -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); + } + +}
\ No newline at end of file diff --git a/libraries/config.default.php b/libraries/config.default.php index 4a5195d442..7e5c6c8ec3 100644 --- a/libraries/config.default.php +++ b/libraries/config.default.php @@ -758,6 +758,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 * * @global boolean $cfg['AllowUserDropDatabase'] diff --git a/libraries/config/messages.inc.php b/libraries/config/messages.inc.php index 583678ff13..d70b19706e 100644 --- a/libraries/config/messages.inc.php +++ b/libraries/config/messages.inc.php @@ -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.' diff --git a/libraries/config/setup.forms.php b/libraries/config/setup.forms.php index 304919c940..5ab03c8b27 100644 --- a/libraries/config/setup.forms.php +++ b/libraries/config/setup.forms.php @@ -162,6 +162,7 @@ $forms['Sql_queries']['Sql_queries'] = array( 'MaxCharactersInDisplayedSQL', 'RetainQueryBox', 'CodemirrorEnable', + 'LintEnable', 'EnableAutocompleteForTablesAndColumns', 'DefaultForeignKeyChecks'); $forms['Sql_queries']['Sql_box'] = array('SQLQuery' => array( diff --git a/libraries/config/user_preferences.forms.php b/libraries/config/user_preferences.forms.php index c150905a6e..d51acd25d0 100644 --- a/libraries/config/user_preferences.forms.php +++ b/libraries/config/user_preferences.forms.php @@ -70,6 +70,7 @@ $forms['Sql_queries']['Sql_queries'] = array( 'MaxCharactersInDisplayedSQL', 'RetainQueryBox', 'CodemirrorEnable', + 'LintEnable', 'EnableAutocompleteForTablesAndColumns', 'DefaultForeignKeyChecks'); $forms['Sql_queries']['Sql_box'] = array( diff --git a/libraries/sql-parser/src/Components/AlterOperation.php b/libraries/sql-parser/src/Components/AlterOperation.php index 7c528283be..9f859e2787 100644 --- a/libraries/sql-parser/src/Components/AlterOperation.php +++ b/libraries/sql-parser/src/Components/AlterOperation.php @@ -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; } diff --git a/libraries/sql-parser/src/Components/Array2d.php b/libraries/sql-parser/src/Components/Array2d.php index c521de2a16..117ad75624 100644 --- a/libraries/sql-parser/src/Components/Array2d.php +++ b/libraries/sql-parser/src/Components/Array2d.php @@ -26,39 +26,38 @@ 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; diff --git a/libraries/sql-parser/src/Components/ArrayObj.php b/libraries/sql-parser/src/Components/ArrayObj.php index 01571defa3..d38077c88d 100644 --- a/libraries/sql-parser/src/Components/ArrayObj.php +++ b/libraries/sql-parser/src/Components/ArrayObj.php @@ -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 === ',') { diff --git a/libraries/sql-parser/src/Components/Expression.php b/libraries/sql-parser/src/Components/Expression.php index 065810c523..964ceb931c 100644 --- a/libraries/sql-parser/src/Components/Expression.php +++ b/libraries/sql-parser/src/Components/Expression.php @@ -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; - } else { - if (!empty($ret->column)) { - break; + + // 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; } - $ret->column = $token->value; + } else { + $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. diff --git a/libraries/sql-parser/src/Components/FieldDefinition.php b/libraries/sql-parser/src/Components/FieldDefinition.php index 0217964068..4df98cb2a5 100644 --- a/libraries/sql-parser/src/Components/FieldDefinition.php +++ b/libraries/sql-parser/src/Components/FieldDefinition.php @@ -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 */ diff --git a/libraries/sql-parser/src/Components/Limit.php b/libraries/sql-parser/src/Components/Limit.php index 1e25d2aedd..86ab55afb7 100644 --- a/libraries/sql-parser/src/Components/Limit.php +++ b/libraries/sql-parser/src/Components/Limit.php @@ -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; diff --git a/libraries/sql-parser/src/Components/OptionsArray.php b/libraries/sql-parser/src/Components/OptionsArray.php index aabb807488..487eefd4d2 100644 --- a/libraries/sql-parser/src/Components/OptionsArray.php +++ b/libraries/sql-parser/src/Components/OptionsArray.php @@ -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); + } } diff --git a/libraries/sql-parser/src/Components/ParameterDefinition.php b/libraries/sql-parser/src/Components/ParameterDefinition.php index db0f815c1e..d10a460f38 100644 --- a/libraries/sql-parser/src/Components/ParameterDefinition.php +++ b/libraries/sql-parser/src/Components/ParameterDefinition.php @@ -120,7 +120,6 @@ class ParameterDefinition extends Component $expr = new ParameterDefinition(); if ($token->value === ',') { $state = 1; - continue; } elseif ($token->value === ')') { ++$list->idx; break; diff --git a/libraries/sql-parser/src/Components/RenameOperation.php b/libraries/sql-parser/src/Components/RenameOperation.php index 335043791a..9a91f81b8f 100644 --- a/libraries/sql-parser/src/Components/RenameOperation.php +++ b/libraries/sql-parser/src/Components/RenameOperation.php @@ -53,6 +53,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. * * Below are the states 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. diff --git a/libraries/sql-parser/src/Components/SetOperation.php b/libraries/sql-parser/src/Components/SetOperation.php index dad91e5425..a9e8ba1624 100644 --- a/libraries/sql-parser/src/Components/SetOperation.php +++ b/libraries/sql-parser/src/Components/SetOperation.php @@ -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. diff --git a/libraries/sql-parser/src/Context.php b/libraries/sql-parser/src/Context.php index 7f937dc2c4..dd912fb279 100644 --- a/libraries/sql-parser/src/Context.php +++ b/libraries/sql-parser/src/Context.php @@ -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); diff --git a/libraries/sql-parser/src/Lexer.php b/libraries/sql-parser/src/Lexer.php index d70641fbdb..aae95f1a58 100644 --- a/libraries/sql-parser/src/Lexer.php +++ b/libraries/sql-parser/src/Lexer.php @@ -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(); diff --git a/libraries/sql-parser/src/Parser.php b/libraries/sql-parser/src/Parser.php index 260ea25a53..f4763063f2 100644 --- a/libraries/sql-parser/src/Parser.php +++ b/libraries/sql-parser/src/Parser.php @@ -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 { diff --git a/libraries/sql-parser/src/Statement.php b/libraries/sql-parser/src/Statement.php index 3739310224..4eb3ccbe63 100644 --- a/libraries/sql-parser/src/Statement.php +++ b/libraries/sql-parser/src/Statement.php @@ -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(); } } diff --git a/libraries/sql-parser/src/Statements/CreateStatement.php b/libraries/sql-parser/src/Statements/CreateStatement.php index 8475448b41..afabc6a8c5 100644 --- a/libraries/sql-parser/src/Statements/CreateStatement.php +++ b/libraries/sql-parser/src/Statements/CreateStatement.php @@ -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 { diff --git a/libraries/sql-parser/src/UtfString.php b/libraries/sql-parser/src/UtfString.php index 27e3f93008..1e863a4546 100644 --- a/libraries/sql-parser/src/UtfString.php +++ b/libraries/sql-parser/src/UtfString.php @@ -93,7 +93,7 @@ class UtfString implements \ArrayAccess */ public function offsetExists($offset) { - return $offset < $this->charLen; + return ($offset >= 0) && ($offset < $this->charLen); } /** @@ -190,26 +190,13 @@ 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. * * @return int @@ -220,30 +207,12 @@ class UtfString implements \ArrayAccess } /** - * Gets the values of the indexes. - * - * @param int &$byte Reference to the byte index. - * @param int &$char Reference to the character index. - * - * @return void - */ - public function getIndexes(&$byte, &$char) - { - $byte = $this->byteIdx; - $char = $this->charIdx; - } - - /** - * Sets the values of the indexes. + * Returns the contained string. * - * @param int $byte The byte index. - * @param int $char The character index. - * - * @return void + * @return strin */ - public function setIndexes($byte = 0, $char = 0) + public function __toString() { - $this->byteIdx = $byte; - $this->charIdx = $char; + return $this->str; } } diff --git a/libraries/sql-parser/src/Utils/Misc.php b/libraries/sql-parser/src/Utils/Misc.php index 9d3dfa99f8..2107172317 100644 --- a/libraries/sql-parser/src/Utils/Misc.php +++ b/libraries/sql-parser/src/Utils/Misc.php @@ -8,6 +8,7 @@ */ namespace SqlParser\Utils; +use SqlParser\Components\Expression; use SqlParser\Statements\SelectStatement; /** diff --git a/libraries/sql-parser/src/Utils/Table.php b/libraries/sql-parser/src/Utils/Table.php index 6b17457d3a..c5ce2fe9db 100644 --- a/libraries/sql-parser/src/Utils/Table.php +++ b/libraries/sql-parser/src/Utils/Table.php @@ -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; } } diff --git a/lint.php b/lint.php new file mode 100644 index 0000000000..b1d4bb414c --- /dev/null +++ b/lint.php @@ -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']); diff --git a/test/libraries/PMA_Linter_Test.php b/test/libraries/PMA_Linter_Test.php new file mode 100644 index 0000000000..56667170a5 --- /dev/null +++ b/test/libraries/PMA_Linter_Test.php @@ -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)); + } +} |