-
Notifications
You must be signed in to change notification settings - Fork 0
Description
前言
上一章讲了数据库的会话创建,以及数据库文件的创建,本章就讲讲在使用Connection获取预编译语句(PrepareStatement)过程中涉及到的JDBC转义序列的内容。
什么是JDBC的转义序列?这里要搞清楚一件事情,不同的数据库厂商在制定SQL语句的标准化上是存在差异的(比如:日期和时间格式、函数等),而JDBC转义序列的作用,就是在SQL语句中插入标准化的转义语法,不同的数据库驱动在运行期自行转换为本地方言, 这样做的目的就是为了提高SQL语法的兼容性,避免在切换不同的数据库时,还需要开发人员介入修改SQL语句。
本地方言转换
当我们在调用JDBC的Connection#prepareStatement(String sql, int resultSetType, int resultSetConcurrency)方法时,会传递3个参数,首先是sql语句,其次是resultSetType(游标类型)和resultSetConcurrency(游标的并发模式)。
// 创建预编译语句
conn.prepareStatement(QUERY,
// 游标类型为可滚动
ResultSet.TYPE_SCROLL_INSENSITIVE,
// 游标并发模式为只读
ResultSet.CONCUR_READ_ONLY)在org.h2.jdbc.JdbcConnection#prepareStatement方法中,主要就是干4件事情,如下所示:
- 检查游标(ResultSet)的类型和并发性
- 检查连接是否有效
- JDBC转义序列处理
- 创建预处理语句(PrepareStatement)
org.h2.jdbc.JdbcConnection#prepareStatement核心代码:
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
try {
int id = getNextId(TraceObject.PREPARED_STATEMENT);// 获取一个UUID
// 省略部分代码...
// 检查游标(ResultSet)的类型和并发性
checkTypeConcurrency(resultSetType, resultSetConcurrency);
// 连接有效性检查
checkClosed();
// 进行JDBC转义序列处理,转换为数据库本地方言
sql = translateSQL(sql);
// 创建预处理语句
return new JdbcPreparedStatement(this, sql, id, resultSetType, resultSetConcurrency, null);
} catch (Exception e) {
throw logAndConvert(e);
}
}org.h2.jdbc.JdbcConnection#checkTypeConcurrency方法的作用是检查游标的类型和并发性
org.h2.jdbc.JdbcConnection#checkTypeConcurrency方法核心逻辑:
private static void checkTypeConcurrency(int resultSetType,
int resultSetConcurrency) {
// 匹配游标类型:TYPE_FORWARD_ONLY、TYPE_FORWARD_ONLY、TYPE_SCROLL_SENSITIVE
// TYPE_SCROLL_INSENSITIVE和TYPE_SCROLL_SENSITIVE可以多次遍历,而TYPE_FORWARD_ONLY只支持一次遍历
switch (resultSetType) {
// TYPE_FORWARD_ONLY:只支持单向遍历结果集(从前往后遍历),性能较高,不反映数据库变化
case ResultSet.TYPE_FORWARD_ONLY:
// TYPE_SCROLL_INSENSITIVE:支持双向滚动遍历,但不反映数据库变化
case ResultSet.TYPE_SCROLL_INSENSITIVE:
// TYPE_SCROLL_SENSITIVE:支持双向滚动遍历,反映数据库变化,但性能较低
case ResultSet.TYPE_SCROLL_SENSITIVE:
break;
default:
throw DbException.getInvalidValueException("resultSetType",
resultSetType);
}
// 匹配游标并发模式:CONCUR_READ_ONLY、CONCUR_UPDATABLE
switch (resultSetConcurrency) {
// 默认的并发模式,只读,不允许对数据库数据产生变更
case ResultSet.CONCUR_READ_ONLY:
// 允许对数据库数据产生变更
case ResultSet.CONCUR_UPDATABLE:
break;
default:
throw DbException.getInvalidValueException("resultSetConcurrency",
resultSetConcurrency);
}
}在JDBC中支持3种游标类型,分别是:TYPE_FORWARD_ONLY、TYPE_FORWARD_ONLY,以及TYPE_SCROLL_SENSITIVE(动态结果集)。TYPE_FORWARD_ONLY只支持单向遍历,也就是说,它只能够向前遍历,不能够向后遍历,也不能设置任意遍历位置,且不反应数据库变化。那什么是反应数据库变化呢?所谓反应数据库变化实际上就是指如果数据库发生数据变更时,重设游标位置后会体现在游标的结果集上,但是这种方式仅TYPE_SCROLL_SENSITIVE类型支持。
TYPE_FORWARD_ONLY和TYPE_SCROLL_SENSITIVE类型都支持双向滚动遍历,也就是说,我们可以向前、向后,或者指定任意位置遍历结果集,不同点为TYPE_SCROLL_SENSITIVE支持反应数据变化。
使用TYPE_SCROLL_INSENSITIVE游标类型双向滚动:
// 向前遍历
while (resultSet.next()) {
resultSet.getString("user_channel");
}
// 游标位置在最后时,向后遍历
while (resultSet.relative(-1)) {
resultSet.getString("user_channel");
}
// 指定位置开始遍历
resultSet.absolute(1);
// 向前遍历
while (resultSet.next()) {
resultSet.getString("user_channel");
}按照性能来看TYPE_FORWARD_ONLY>TYPE_FORWARD_ONLY>TYPE_SCROLL_SENSITIVE,一般情况下,推荐使用默认的游标类型TYPE_FORWARD_ONLY,当然传入不支持的游标类型将会抛出异常。
游标类型检查确认完后,在checkTypeConcurrency方法中接下来会检查游标的并发模式,JDBC支持2种并发模式,即:CONCUR_READ_ONLY和CONCUR_UPDATABLE。CONCUR_READ_ONLY是缺省的并发模式,是只读的,不允许通过游标对数据产生变更,而CONCUR_UPDATABLE则允许对数据库产生变更。关于游标的源码分析,后续章节会单独拉一章进行讲解,本文不再叙述。同样的,找不到指定的并发模式时,也会抛出异常信息。
在在org.h2.jdbc.JdbcConnection#prepareStatement方法中执行完游标类检查后,接下来就是检查数据库连接的有效性,实际上就是检查连接是不是已经关闭了。
org.h2.jdbc.JdbcConnection#checkClosed方法核心逻辑:
protected void checkClosed() {
if (session == null) {
throw DbException.get(ErrorCode.OBJECT_CLOSED);
}
if (session.isClosed()) {
throw DbException.get(ErrorCode.DATABASE_CALLED_AT_SHUTDOWN);
}
}这里没什么好讲的,接下来就是进入到本章的重点,JDBC转义序列处理。
org.h2.jdbc.JdbcConnection#translateSQL方法核心逻辑:
static String translateSQL(String sql, boolean escapeProcessing) {
if (sql == null) {// SQL的非空性检查
throw DbException.getInvalidValueException("SQL", null);
}
// JDBC转义序列的语法标准都是包含在大括号"{}"中
// 标准的JDBC转义语法:SELECT * FROM orders WHERE order_date = {d '2024-01-01'};,其中{d '2024-01-01'}就是JDBC转移序列语法
// 如果不需要进行JDBC转义序列处理,或者不包含大括号"{}",则直接返回sql
if (!escapeProcessing || sql.indexOf('{') < 0) {
return sql;
}
// 解析转义序列
return translateSQLImpl(sql);
}需要注意的是,JDBC转义序列的标准语法是包含在大括号”{}“中的,因此在进行转义解析前,会先判断SQL中是否存在左括号”{“,或者是否需要进行转义处理,如果一项不满足的时候,就直接返回源SQL,反之调用translateSQLImpl方法进入到解析逻辑。
org.h2.jdbc.JdbcConnection#translateSQLImpl方法核心逻辑:
private static String translateSQLImpl(String sql) {
int len = sql.length();// 获取sql字符总长度
char[] chars = null;
int level = 0;
for (int i = 0; i < len; i++) {// 依次遍历sql字符
char c = sql.charAt(i);
switch (c) {// 这里只处理\'、"、/、-、{、}、$这几种字符
case '\'':
case '"':
case '/':
case '-':
// 返回目标字符的下一个位置,假设字符c是单引号\',函数查找并返回下一个单引号的位置
i = translateGetEnd(sql, i, c);
break;
case '{':
// level的作用是跟踪嵌套大括号{}的层次深度。每遇到一个左大括号{,level 增加 1;
// 每遇到一个右大括号},level减少1。通过这个机制,可以确保大括号的匹配,即每个左大括号都必须有一个对应的右大括号,并且在结束时level的值应该回到0,否则异常
level++;
if (chars == null) {
chars = sql.toCharArray();
}
chars[i] = ' ';// 找到{索引位,并将{设置为空格
while (Character.isSpaceChar(chars[i])) {// 检查当前字符是否是空白字符(空格、制表符、换行符等),如果是就跳过当前字符
i++;
checkRunOver(i, len, sql);// 数组越界检查
}
int start = i;// {之后的第一个字符索引位
if (chars[i] >= '0' && chars[i] <= '9') {// 如果字符是数字,就还原{
chars[i - 1] = '{';
while (true) {
checkRunOver(i, len, sql);
c = chars[i];
if (c == '}') {
break;
}
switch (c) {
case '\'':
case '"':
case '/':
case '-':
i = translateGetEnd(sql, i, c);
break;
default:
}
i++;
}
level--;
break;
} else if (chars[i] == '?') {// 判断当前字符是不是?
i++;
checkRunOver(i, len, sql);
while (Character.isSpaceChar(chars[i])) {
i++;
checkRunOver(i, len, sql);
}
if (sql.charAt(i) != '=') {
throw DbException.getSyntaxError(sql, i, "=");
}
i++;
checkRunOver(i, len, sql);
while (Character.isSpaceChar(chars[i])) {
i++;
checkRunOver(i, len, sql);
}
}
while (!Character.isSpaceChar(chars[i])) {// 找到空白符退出
i++;
checkRunOver(i, len, sql);
}
// 区间字符检查,这里的检查主要是fn、ob、params要从SQL中删除
int remove = 0;
if (found(sql, start, "fn")) {
remove = 2;
} else if (found(sql, start, "escape")) {
break;
} else if (found(sql, start, "call")) {
break;
} else if (found(sql, start, "oj")) {
remove = 2;
} else if (found(sql, start, "ts")) {
break;
} else if (found(sql, start, "t")) {
break;
} else if (found(sql, start, "d")) {
break;
} else if (found(sql, start, "params")) {
remove = "params".length();
}
for (i = start; remove > 0; i++, remove--) {
chars[i] = ' ';
}
break;
case '}':
if (--level < 0) {
throw DbException.getSyntaxError(sql, i);
}
chars[i] = ' ';// 把符号}替换为空白符
break;
case '$':
i = translateGetEnd(sql, i, c);
break;
default:
}
}
if (level != 0) {
throw DbException.getSyntaxError(sql, sql.length() - 1);
}
if (chars != null) {
sql = new String(chars);
}
return sql;
}H2支持的转义序列如下:
- 日期:{d 'yyyy-mm-dd'}
- 时间:{t 'hh:mm:ss'}
- 时间戳:{ts 'yyyy-mm-dd hh:mm:ss.f'}
- 函数调用:{fn function_name(arguments)}
- 存储过程调用:{call procedure_name(parameters)}
- 外部连接:{oj ...}
- 转义字符:{escape 'char'}
转义序列解析的时候,做2件事情,第一是去除大括号”{}“,第二就是转换为本地数据库方言。比如转义SQL:
SELECT {fn UCASE(customer_name)} FROM customers解析完后的数据库方言如下:
SELECT UCASE(customer_name) FROM customers如上所示,在执行translateSQLImpl方法时,H2的本地方言解析首先会去除大括号”{}“,然后再去掉符号”fn“。接下来我们再来看看translateSQLImpl方法是如何解析转义序列”{d 'yyyy-mm-dd'}“的。
在方法的顶部,首先会将sql转换为char[],然后依次遍历每一个字符进行处理。当遇到左大括号”{“时,首先会将其替换为空白符,然后找到找到下一个空白符之前的H2所支持的转义序列的符号(比如:d、t、fn等),判断是该移除替换,还是保留。当遍历到字符为”'“时,说明是一个单引号,会调用translateGetEnd方法查找并返回下一个对等符号的位置。
org.h2.jdbc.JdbcConnection#translateGetEnd方法核心逻辑:
private static int translateGetEnd(String sql, int i, char c) {
int len = sql.length();
switch (c) {
// todo 找到下一个单引号位置
case '\'': {
int j = sql.indexOf('\'', i + 1);
if (j < 0) {
throw DbException.getSyntaxError(sql, i);
}
return j;
}
// 省略其他逻辑...
default:
throw DbException.getInternalError("c=" + c);
}
}最后当遍历的字符为右括号"}"时将会替换为空白符,然后返回解析完的SQL语句,至此JDBC转义序列解析结束。