Skip to content

RDBMS-H2源码剖析-JDBC转义序列&本地方言转换 #8

@gaoxianglong

Description

@gaoxianglong

前言

上一章讲了数据库的会话创建,以及数据库文件的创建,本章就讲讲在使用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转义序列解析结束。

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions