最近公司项目,需要用到Codemirror编辑器来实现一个网页版的OracleSQL编辑器。但Codemirror的提示功能只支持到 表.列 这种级别的提示,不支持Oracle常用的 用户.表.列 的提示,所以进行改写。
先从Codemirror的网站下载Codemirror的文件,并放入自己的项目中。下载下来的项目中包含有Demo,而且根据你环境的不同可能会报错,不过一般不要紧。
这里先引入了Codemirror所需的文件,然后在界面中构建了一个textarea,然后使用Codemirror的formTextArea函数,将区域转化成Codemirror的编辑器,同时,设置了行号、高亮活动行、主题、模式、提示数据等属性;同时绑定了自动提示的函数。其他属性我就不详细解释了,网上有很多相关的文章,这里我着重说一下提示相关的东西:
mode : 'text/x-mysql':这个属性是设定提示的模式,我这里选择了MySQL的模式,可以提供一些MySQL相关的基础字段的提示,如select、delete等。
source变量:里面存储了两级结构:user包含id和name;dbs包含id和name,也就是user表有id和name两列;dbs包含id和name两列。
hintOptions: {tables: source}:这个属性是将source变量绑定至提示功能的tables属性,SQL-hint功能会自动获取tables这个属性的值来进行提示。这里后面会详细讲解,先知道个大概就可以。
editor.on("keyup",function(){}....:这个部分是将提示功能绑定到键盘上的,我去除了部分按键,大家可以根据自己的需求,调整判定条件,所用到的keycode可以搜索到。
至此,已经实现了一个拥有提示功能的SQL编辑器。如果你是想为MySQL或其他数据库提供提示功能,到这里就可以了。如果你想继续了解Codemirror提示功能的实现方式,或者也想为Oracle提供功能,请继续往下看。
通过观察我们引入的js文件,可以很轻易的看出,代码提示功能主要是通过show-hint.js和sql-hint.js两个文件实现的。
show-hint提供了一些基础的提示功能,这里我们不做研究,有兴趣的可以自行探索。
sql-hint提供了SQL相关的提示功能,下面我们来一起研究一下。
CodeMirror.registerHelper("hint", "sql", function(editor, options) {
tables = parseTables(options && options.tables)
var defaultTableName = options && options.defaultTable;
var disableKeywords = options && options.disableKeywords;
defaultTable = defaultTableName && getTable(defaultTableName);
keywords = getKeywords(editor);
identifierQuote = getIdentifierQuote(editor);
if (defaultTableName && !defaultTable)
defaultTable = findTableByAlias(defaultTableName, editor);
defaultTable = defaultTable || [];
if (defaultTable.columns)
defaultTable = defaultTable.columns;
var cur = editor.getCursor();
var result = [];
var token = editor.getTokenAt(cur), start, end, search;
if (token.end > cur.ch) {
token.end = cur.ch;
token.string = token.string.slice(0, cur.ch - token.start);
}
if (token.string.match(/^[.`"\w@]\w*$/)) {
search = token.string;
start = token.start;
end = token.end;
} else {
start = end = cur.ch;
search = "";
}
if (search.charAt(0) == "." || search.charAt(0) == identifierQuote) {
start = nameCompletion(cur, token, result, editor);
} else {
addMatches(result, search, defaultTable, function(w) {return {text:w, className: "CodeMirror-hint-table CodeMirror-hint-default-table"};});
addMatches(
result,
search,
tables,
function(w) {
if (typeof w === 'object') {
w.className = "CodeMirror-hint-table";
} else {
w = {text: w, className: "CodeMirror-hint-table"};
}
return w;
}
);
if (!disableKeywords)
addMatches(result, search, keywords, function(w) {return {text: w.toUpperCase(), className: "CodeMirror-hint-keyword"};});
}
return {list: result, from: Pos(cur.line, start), to: Pos(cur.line, end)};
});
这个函数是SQL智能提示功能的主函数,是通过CodeMirror.registerHelper方法绑定到Codemirror编辑器的。代码健壮性相关的内容我们忽略,只来关注提示功能的核心:
首先,tables是从Codemirror中获取的,也就是我们在配置Codemirror的时候设置的source。之后,通过token获取到键入的内容和位置相关,经过一系列的匹配和校验,获取到关键词search,然后根据search的内容,来决定进入哪个提示函数。提示函数共有两个:nameCompletion和addMatches。nameCompletion是提供级联查询的(也就是“.”后面的提示),addMatches是关键词的检索。其实nameCompletion内部也是通过addMatches来实现的,所以我们先来看addMatches。
function addMatches(result, search, wordlist, formatter) {
if (isArray(wordlist)) {
for (var i = 0; i < wordlist.length; i++)
if (match(search, wordlist[i])) result.push(formatter(wordlist[i]))
} else {
for (var word in wordlist) if (wordlist.hasOwnProperty(word)) {
var val = wordlist[word]
if (!val || val === true)
val = word
else
val = val.displayText ? {text: val.text, displayText: val.displayText} : val.text
if (match(search, val)) result.push(formatter(val))
}
}
}
入参如下:result用于存储检索结果,search是关键词,wordlist是待匹配的json数据,formatter是结果的格式化函数。
首先判定wordList是一个json数组还是一个json对象,数组的话遍历数组,对象的话遍历对象,根据每个元素的单词与search进行比对(调用match函数,可自行从sql-hint.js中查阅函数内容),如果比对通过,则将该项格式化后放入result中。
接下来我们来看nameCompletion:
function nameCompletion(cur, token, result, editor) {
// Try to complete table, column names and return start position of completion
var useIdentifierQuotes = false;
var nameParts = [];
var start = token.start;
var cont = true;
while (cont) {
cont = (token.string.charAt(0) == ".");
useIdentifierQuotes = useIdentifierQuotes || (token.string.charAt(0) == identifierQuote);
start = token.start;
nameParts.unshift(cleanName(token.string));
token = editor.getTokenAt(Pos(cur.line, token.start));
if (token.string == ".") {
cont = true;
token = editor.getTokenAt(Pos(cur.line, token.start));
}
}
// Try to complete table names
var string = nameParts.join(".");
addMatches(result, string, tables, function(w) {
return useIdentifierQuotes ? insertIdentifierQuotes(w) : w;
});
// Try to complete columns from defaultTable
addMatches(result, string, defaultTable, function(w) {
return useIdentifierQuotes ? insertIdentifierQuotes(w) : w;
});
// Try to complete columns
string = nameParts.pop();
var table = nameParts.join(".");
var alias = false;
var aliasTable = table;
// Check if table is available. If not, find table by Alias
if (!getTable(table)) {
var oldTable = table;
table = findTableByAlias(table, editor);
if (table !== oldTable) alias = true;
}
var columns = getTable(table);
if (columns && columns.columns)
columns = columns.columns;
if (columns) {
addMatches(result, string, columns, function(w) {
var tableInsert = table;
if (alias == true) tableInsert = aliasTable;
if (typeof w == "string") {
w = tableInsert + "." + w;
} else {
w = shallowClone(w);
w.text = tableInsert + "." + w.text;
}
return useIdentifierQuotes ? insertIdentifierQuotes(w) : w;
});
}
return start;
}
入参:cur、token、editor都是编辑器相关的参数,result是结果。
第一个while循环是根据游标相关的数据,筛选分割出用户输入的词组存入nameParts,并调整相关游标。例如:dwd.aa,则在循环之后,nameParts为[dwd,aa]。之后,调用两次addMatches,分别从tables和defaultTables中筛选词组(defaultTables就是Codemirror自带的默认词组,如select、delete等)。
之后,新建了一个变量alias,并有一些相关的操作。这里是用来判断用户当前输入的是否是表的昵称,并用昵称进行搜索。(例如:select * from oasystem o where o.name = 'aaa'; 这里的o就是表oasystem的昵称。)判定昵称的核心函数是:findTableByAlias,入参:table 待匹配的json数据,editor 编辑器。关于findTableByAlias及相关的功能,这里不再详细描述,有兴趣的可以自行查看源码。总之,经过这个函数之后,如果用户输入的是昵称,则待匹配的table数据就变成了昵称所对应的那一部分数据的表名。(吐槽:JavaScript同一个变量前后存储不同的数据的情况经常出现,这个变量一会儿存表名一会儿存表结构数据,弄的人晕头转向,反人类!!!--------来自一个原java程序猿的哀嚎)
最后,使用一个addMatches,对刚才筛选后的columns和string进行筛选,然后将结果返回。string是关键词,columns是待匹配的数据。
现在我们了解了addMatches和nameCompletion两个筛选函数,再回头看主函数,我们就明白了整个筛选的过程。
明白流程后就好办了,我们的需求是在原有两层结构的基础上,使之支持三层甚至更多层的结构,主要修改的是nameCompletion函数,其他函数根据需求也有部分修改。下面上我改写后的源码:
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: http://codemirror.net/LICENSE
//作者:CoderLV
//日期:2019-1-2
//说明:适配Oracle使用习惯的Codemirror——SQL智能提醒功能。添加了user相关的提醒功能。
//输入数据示例:
//var source = {"oa": {"users" : ["aa","bb"],"groups" : ["cc","dd"]}};
//数据说明:用户--oa;表名--users、groups;users表下的列:aa,bb;groups表下的列:cc,dd。
(function(mod) {
if (typeof exports == "object" && typeof module == "object") // CommonJS
mod(require("../../lib/codemirror"), require("../../mode/sql/sql"));
else if (typeof define == "function" && define.amd) // AMD
define(["../../lib/codemirror", "../../mode/sql/sql"], mod);
else // Plain browser env
mod(CodeMirror);
})(function(CodeMirror) {
var tables;
var defaultTable;
var keywords;
var identifierQuote;
var CONS = {
QUERY_DIV: ";",
ALIAS_KEYWORD: "AS"
};
var Pos = CodeMirror.Pos,
cmpPos = CodeMirror.cmpPos;
function getKeywords(editor) {
var mode = editor.doc.modeOption;
if (mode === "sql") mode = "text/x-sql";
return CodeMirror.resolveMode(mode).keywords;
}
function getIdentifierQuote(editor) {
var mode = editor.doc.modeOption;
if (mode === "sql") mode = "text/x-sql";
return CodeMirror.resolveMode(mode).identifierQuote || "`";
}
function cleanName(name) {
// 获取名称并且去除“.”
if (name.charAt(0) == ".") {
name = name.substr(1);
}
// 使用单引号替换双引号
// 并且去除单个引号
var nameParts = name.split(identifierQuote+identifierQuote);
for (var i = 0; i < nameParts.length; i++)
nameParts[i] = nameParts[i].replace(new RegExp(identifierQuote,"g"), "");
return nameParts.join(identifierQuote);
}
function isArray(val) { //判定val是否是数组
return Object.prototype.toString.call(val) == "[object Array]"
}
function getText(item) { //获取item的字符串表达
return typeof item == "string" ? item : item.text;
}
function match(string, word) { //判定word是否以string开头
var len = string.length;
var sub = getText(word).substr(0, len);
return string.toUpperCase() === sub.toUpperCase();
}
function addMatches(result, search, wordlist, formatter) { //根据search搜索wordlist对象,将符合条件的结果通过formatter格式化,然后添加到result数组中。
if (isArray(wordlist)) { //如果是数组,则表示传入的worklist包含的是列名结构。
for (var i = 0; i < wordlist.length; i++) {
if (match(search, wordlist[i])) {
result.push(formatter(wordlist[i]));
}
}
} else { //如果不是数组,则表示传入的worklist包含的是用户名或者表名结构。
for (var world in wordlist) {
if (match(search, world)) {
result.push(formatter(world));
}
}
}
}
function fetchStartPoint(token) {//根据token获取截取开始的位置
var index = token.string.lastIndexOf("\.");
if (index < 0) {
return token.start;
} else {
return token.start + index + 1;
}
};
function getTableByAllTableName(allTableName){//根据全表名从tables中获取表Obj。如果找不到该表,则返回null。全表名用“.”分割。
var nameParts = allTableName.split(".");
var theTable = tables;
for(var i = 0; i < nameParts.length; i++){
if (theTable.hasOwnProperty(nameParts[i])){
theTable = theTable[nameParts[i]];
}else{
theTable = null;
break;
}
}
return theTable;
}
function findTableByAlias(alias, editor) {//尝试通过昵称查询表名
var doc = editor.doc;
var fullQuery = doc.getValue();
var aliasUpperCase = alias.toUpperCase();
var previousWord = "";
var table = "";
var separator = [];
var validRange = {
start: Pos(0, 0),
end: Pos(editor.lastLine(), editor.getLineHandle(editor.lastLine()).length)
};
//add separator
var indexOfSeparator = fullQuery.indexOf(CONS.QUERY_DIV);
while(indexOfSeparator != -1) {
separator.push(doc.posFromIndex(indexOfSeparator));
indexOfSeparator = fullQuery.indexOf(CONS.QUERY_DIV, indexOfSeparator+1);
}
separator.unshift(Pos(0, 0));
separator.push(Pos(editor.lastLine(), editor.getLineHandle(editor.lastLine()).text.length));
//find valid range
var prevItem = null;
var current = editor.getCursor()
for (var i = 0; i < separator.length; i++) {
if ((prevItem == null || cmpPos(current, prevItem) > 0) && cmpPos(current, separator[i]) <= 0) {
validRange = {start: prevItem, end: separator[i]};
break;
}
prevItem = separator[i];
}
if (validRange.start) {
var query = doc.getRange(validRange.start, validRange.end, false);
for (var i = 0; i < query.length; i++) {
var lineText = query[i];
eachWord(lineText, function(word) {
var wordUpperCase = word.toUpperCase();
if (wordUpperCase === aliasUpperCase && getTableByAllTableName(previousWord))
table = previousWord;
if (wordUpperCase !== CONS.ALIAS_KEYWORD)
previousWord = word;
});
if (table) break;
}
}
return table;
}
function eachWord(lineText, f) {
var words = lineText.split(/\s+/)
for (var i = 0; i < words.length; i++)
if (words[i]) f(words[i].replace(/[,;]/g, ''))
}
function nameCompletion(cur, token, result, editor) {
// Try to complete table, column names and return start position of completion
var useIdentifierQuotes = false;
var nameParts = [];
var start = fetchStartPoint(token);
var cont = true;
while (cont) {//获取表名词组并存储到nameParts
cont = (token.string.charAt(0) == ".");
useIdentifierQuotes = useIdentifierQuotes || (token.string.charAt(0) == identifierQuote);
nameParts.unshift(cleanName(token.string));
token = editor.getTokenAt(Pos(cur.line, token.start));
if (token.string == ".") {
cont = true;
token = editor.getTokenAt(Pos(cur.line, token.start));
}
}
var theLastString = nameParts.pop();
var allTableName = nameParts.join(".");
var theTable = getTableByAllTableName(allTableName);//尝试根据全表名获取表Obj
if(theTable == null && nameParts.length == 1){//如果不能根据全表名获取到Obj,并且nameParts长度为1,则尝试根据表昵称获取表Obj
var theTableName = findTableByAlias(nameParts[0],editor);
theTable = getTableByAllTableName(theTableName);
}
addMatches( //匹配当前位置的用户、表、列
result,
theLastString,
theTable,
function(w) { //将返回结果包装成标准格式,并赋予table类名
if (typeof w === 'object') {
w.className = "CodeMirror-hint-table";
} else {
w = {
text: w,
className: "CodeMirror-hint-table"
};
}
return w;
}
);
return start;
}
//绑定智能提醒事件处理函数
CodeMirror.registerHelper("hint", "sql", function(editor, options) {
tables = options && options.tables;
var disableKeywords = options && options.disableKeywords;
keywords = getKeywords(editor);
identifierQuote = getIdentifierQuote(editor);
var cur = editor.getCursor();
var result = [];
var token = editor.getTokenAt(cur),
start, end, search;
if (token.end > cur.ch) {
token.end = cur.ch;
token.string = token.string.slice(0, cur.ch - token.start);
}
if (token.string.match(/^[.`"\w@]\w*$/)) {
search = token.string;
start = token.start;
end = token.end;
} else {
start = end = cur.ch;
search = "";
}
if (search.charAt(0) == "." || search.charAt(0) == identifierQuote) {
start = nameCompletion(cur, token, result, editor);
} else {
addMatches( //匹配用户、表、列
result,
search,
tables,
function(w) { //将返回结果包装成标准格式,并赋予table类名
if (typeof w === 'object') {
w.className = "CodeMirror-hint-table";
} else {
w = {
text: w,
className: "CodeMirror-hint-table"
};
}
return w;
}
);
if (!disableKeywords) //匹配内置关键词
addMatches(result, search, keywords, function(w) {
return {
text: w.toUpperCase(),
className: "CodeMirror-hint-keyword"
};
});
}
return {
list: result,
from: Pos(cur.line, start),
to: Pos(cur.line, end)
};
});
});
代码中有注释和数据的说明,应该不难理解,就不再讲解了。
性能相关的题外话:
本以为数据量大了之后,性能会出现问题。但是经过实际测试,总数据量在几万条时,仍旧可以保证实时的提示,不会感到卡顿,因为项目总共就只有这种级别的数据,就没有再继续增加数据量进行测试。对大部分场景来说,这个级别的性能应该是够用的。