CodeMirror SQL智能提示功能的使用、源码解析与改写,使之支持Oracle的使用习惯

最近公司项目,需要用到Codemirror编辑器来实现一个网页版的OracleSQL编辑器。但Codemirror的提示功能只支持到 表.列 这种级别的提示,不支持Oracle常用的 用户.表.列 的提示,所以进行改写。

先简单介绍一下Codemirror的使用方式:










先从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提供功能,请继续往下看。

Codemirror SQL提示功能源码解析:

通过观察我们引入的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)
		};
	});
});

代码中有注释和数据的说明,应该不难理解,就不再讲解了。

性能相关的题外话:

本以为数据量大了之后,性能会出现问题。但是经过实际测试,总数据量在几万条时,仍旧可以保证实时的提示,不会感到卡顿,因为项目总共就只有这种级别的数据,就没有再继续增加数据量进行测试。对大部分场景来说,这个级别的性能应该是够用的。

你可能感兴趣的:(CodeMirror)