1. 概述

本文将探讨 Java 对 HarperDB 的支持能力——一款兼具 NoSQL 灵活性与 SQL 强大功能的高性能数据库。毫无疑问,标准 Java 数据库连接(JDBC)使其能轻松集成主流 BI 工具、报表工具、ETL 工具及各类定制应用。同时,它还提供 REST API 用于数据库管理和操作。

不过,JDBC 显著简化了 HarperDB 在应用中的集成流程,能大幅加速开发进程。

本文将使用 Java Test Container 库 启动 HarperDB Docker 容器,通过实际案例演示完整集成方案。

接下来通过示例深入探索 HarperDB 的 JDBC 支持能力。

2. JDBC 库

HarperDB 提供了 JDBC 驱动,需在 pom.xml 中手动引入:

<dependency>
    <groupId>com.baeldung</groupId>
    <artifactId>java-harperdb</artifactId>
    <version>4.2</version>
    <scope>system</scope>
    <systemPath>${project.basedir}/lib/cdata.jdbc.harperdb.jar</systemPath>
</dependency>

⚠️ 该驱动未发布到公共 Maven 仓库,需从本地目录或私有仓库导入。

3. 建立 JDBC 连接

在执行 SQL 语句前,我们先探讨如何获取 java.sql.Connection 对象

先看第一种方式:

@Test
void whenConnectionInfoInURL_thenConnectSuccess() {
    assertDoesNotThrow(() -> {
        final String JDBC_URL = "jdbc:harperdb:Server=127.0.0.1:" + port + ";User=admin;Password=password;";

        try (Connection connection = DriverManager.getConnection(JDBC_URL)) {
            connection.createStatement().executeQuery("select 1");
            logger.info("Connection Successful");
        }
    });
}

与传统关系型数据库连接方式类似,区别仅在于 JDBC URL 的 jdbc:harperdb: 前缀。生产环境中密码应加密后再传入 URL

再看第二种方式:

@Test
void whenConnectionInfoInProperties_thenConnectSuccess() {
    assertDoesNotThrow(() -> {
        Properties prop = new Properties();
        prop.setProperty("Server", "127.0.0.1:" + port);
        prop.setProperty("User", "admin");
        prop.setProperty("Password", "password");

        try (Connection connection = DriverManager.getConnection("jdbc:harperdb:", prop)) {
            connection.createStatement().executeQuery("select 1");
            logger.info("Connection Successful");
        }
    });
}

这里改用 Properties 对象传递连接参数到 DriveManager

为追求最佳性能,应用通常采用连接池。HarperDB 的 JDBC 驱动同样支持:

@Test
void whenConnectionPooling_thenConnectSuccess() {
    assertDoesNotThrow(() -> {
        HarperDBConnectionPoolDataSource harperdbPoolDataSource = new HarperDBConnectionPoolDataSource();
        final String JDBC_URL = "jdbc:harperdb:UseConnectionPooling=true;PoolMaxSize=2;Server=127.0.0.1:" + port
          + ";User=admin;Password=password;";
        harperdbPoolDataSource.setURL(JDBC_URL);

        try(Connection connection = harperdbPoolDataSource.getPooledConnection().getConnection()) {
            connection.createStatement().executeQuery("select 1");
            logger.info("Connection Successful");
        }
    });
}

通过 UseConnectionPooling=true 启用连接池,并需使用 HarperDBConnectionPoolDataSource 驱动类获取连接。其他连接属性可参考官方文档

4. 创建 Schema 和表

HarperDB 提供 RESTful 数据库操作 API 用于配置管理,也支持创建数据库对象和执行 SQL CRUD 操作。

不支持 Create TableCreate Schema 等 DDL 语句。取而代之的是,**HarperDB 提供存储过程**:

@Test
void whenExecuteStoredToCreateTable_thenSuccess() throws SQLException {
    final String CREATE_TABLE_PROC = "CreateTable";
    try (Connection connection = getConnection()) {
        CallableStatement callableStatement = connection.prepareCall(CREATE_TABLE_PROC);

        callableStatement.setString("SchemaName", "Prod");
        callableStatement.setString("TableName", "Subject");
        callableStatement.setString("PrimaryKey", "id");
        Boolean result = callableStatement.execute();

        ResultSet resultSet = callableStatement.getResultSet();

        while (resultSet.next()) {
            String tableCreated = resultSet.getString("Success");
            assertEquals("true", tableCreated);
        }
    }
}

通过 CallableStatement 调用 CreateTable 存储过程,在 Prod schema 中创建 Subject。该过程接收 SchemaNameTableNamePrimaryKey 参数。有趣的是,无需显式创建 schema——若不存在会自动创建。

类似地,其他存储过程(如 CreateHarperSchemaDropSchemaDropTable 等)也可通过 CallableStatement 调用。

5. CRUD 支持

HarperDB JDBC 驱动支持完整的 CRUD 操作,可通过 java.sql.Statementjava.sql.PreparedStatement 对表进行增删改查

5.1. 数据模型

先准备测试数据。假设存在 Demo schema,包含三张表:

数据模型

SubjectTeacher 是主表,Teacher_Details 存储教师授课信息。注意:HarperDB 不支持外键约束,因此 teacher_idsubject_id 字段无约束关系

Subject 表数据示例:

[
  {"id":1, "name":"English"},
  {"id":2, "name":"Maths"},
  {"id":3, "name":"Science"}
]

Teacher 表数据示例:

[
  {"id":1, "name":"James Cameron", "joining_date":"04-05-2000"},
  {"id":2, "name":"Joe Biden", "joining_date":"20-10-2005"},
  {"id":3, "name":"Jessie Williams", "joining_date":"04-06-1997"},
  {"id":4, "name":"Robin Williams", "joining_date":"01-01-2020"},
  {"id":5, "name":"Eric Johnson", "joining_date":"04-05-2022"},
  {"id":6, "name":"Raghu Yadav", "joining_date":"02-02-1999"}
]

Teacher_Details 表数据示例:

[
  {"id":1, "teacher_id":1, "subject_id":1},
  {"id":2, "teacher_id":1, "subject_id":2},
  {"id":3, "teacher_id":2, "subject_id":3 },
  {"id":4, "teacher_id":3, "subject_id":1},
  {"id":5, "teacher_id":3, "subject_id":3},
  {"id":6, "teacher_id":4, "subject_id":2},
  {"id":7, "teacher_id":5, "subject_id":3},
  {"id":8, "teacher_id":6, "subject_id":1},
  {"id":9, "teacher_id":6, "subject_id":2},
  {"id":15, "teacher_id":6, "subject_id":3}
]

所有表的 id 字段均为主键。

5.2. 使用 Insert 创建记录

Subject 表插入新科目:

@Test
void givenStatement_whenInsertRecord_thenSuccess() throws SQLException {
    final String INSERT_SQL = "insert into Demo.Subject(id, name) values "
      + "(4, 'Social Studies'),"
      + "(5, 'Geography')";

    try (Connection connection = getConnection()) {
        Statement statement = connection.createStatement();
        assertDoesNotThrow(() -> statement.execute(INSERT_SQL));
        assertEquals(2, statement.getUpdateCount());
    }
}

使用 java.sql.Statement 插入两条记录。

更优方案是使用 java.sql.PreparedStatement(以 Teacher 表为例):

@Test
void givenPrepareStatement_whenAddToBatch_thenSuccess() throws SQLException {
    final String INSERT_SQL = "insert into Demo.Teacher(id, name, joining_date) values"
      + "(?, ?, ?)";

    try (Connection connection = getConnection()) {
        PreparedStatement preparedStatement = connection.prepareStatement(INSERT_SQL);
        preparedStatement.setInt(1, 7);
        preparedStatement.setString(2, "Bret Lee");
        preparedStatement.setString(3, "07-08-2002");
        preparedStatement.addBatch();

        preparedStatement.setInt(1, 8);
        preparedStatement.setString(2, "Sarah Glimmer");
        preparedStatement.setString(3, "07-08-1997");
        preparedStatement.addBatch();

        int[] recordsInserted = preparedStatement.executeBatch();

        assertEquals(2, Arrays.stream(recordsInserted).sum());
    }
}

通过参数化 SQL 和批处理方法addBatch() + *executeBatch()*)提升性能。批量处理对海量数据插入至关重要,HarperDB 的 JDBC 驱动支持此特性极具价值。

5.3. 使用 Insert Into Select 创建记录

HarperDB JDBC 驱动支持运行时创建临时表,后续可通过单条 insert into select 语句将数据导入目标表。类似批处理,此特性可减少数据库调用次数

实战示例:

@Test
void givenTempTable_whenInsertIntoSelectTempTable_thenSuccess() throws SQLException {
    try (Connection connection = getConnection()) {
        Statement statement = connection.createStatement();
        assertDoesNotThrow(() -> {
            statement.execute("insert into Teacher#TEMP(id, name, joining_date) "
              + "values('12', 'David Flinch', '04-04-2014')");
            statement.execute("insert into Teacher#TEMP(id, name, joining_date) "
              + "values('13', 'Stephen Hawkins', '04-07-2017')");
            statement.execute("insert into Teacher#TEMP(id, name, joining_date) "
              + "values('14', 'Albert Einstein', '12-08-2020')");
            statement.execute("insert into Teacher#TEMP(id, name, joining_date) "
              + "values('15', 'Leo Tolstoy', '20-08-2022')");
        });
        assertDoesNotThrow(() -> statement.execute("insert into Demo.Teacher(id, name, joining_date) "
          + "select id, name, joining_date from Teacher#TEMP"));
        ResultSet resultSet = statement.executeQuery("select count(id) as rows from Demo.Teacher where id in"
          + " (12, 13, 14, 15)");
        resultSet.next();
        int totalRows = resultSet.getInt("rows");
        assertEquals(4, totalRows);
    }
}

临时表命名必须遵循 [表名]#TEMP 格式(如 Teacher#TEMP)。执行 insert 语句时自动创建临时表,四条记录插入后,通过单条 insert into select 导入目标 Teacher 表。

5.4. 读取表记录

先用 java.sql.Statement 查询 Subject 表:

@Test
void givenStatement_whenFetchRecord_thenSuccess() throws SQLException {
    final String SQL_QUERY = "select id, name from Demo.Subject where name = 'Maths'";

    try (Connection connection = getConnection()) {
        Statement statement = connection.createStatement();
        ResultSet resultSet = statement.executeQuery(SQL_QUERY);
        while (resultSet.next()) {
            Integer id = resultSet.getInt("id");
            String name = resultSet.getString("name");
            assertNotNull(id);
            logger.info("Subject id:" + id + " Subject Name:" + name);
        }
    }
}

executeQuery() 成功执行并返回结果。

验证 java.sql.PreparedStatement 支持,执行带 JOIN 的复杂查询:

@Test
void givenPreparedStatement_whenExecuteJoinQuery_thenSuccess() throws SQLException {
    final String JOIN_QUERY = "SELECT t.name as teacher_name, t.joining_date as joining_date, s.name as subject_name "
      + "from Demo.Teacher_Details AS td "
      + "INNER JOIN Demo.Teacher AS t ON t.id = td.teacher_id "
      + "INNER JOIN Demo.Subject AS s on s.id = td.subject_id "
      + "where t.name = ?";

    try (Connection connection = getConnection()) {
        PreparedStatement preparedStatement = connection.prepareStatement(JOIN_QUERY);
        preparedStatement.setString(1, "Eric Johnson");

        ResultSet resultSet = preparedStatement.executeQuery();
        while (resultSet.next()) {
            String teacherName = resultSet.getString("teacher_name");
            String subjectName = resultSet.getString("subject_name");
            String joiningDate = resultSet.getString("joining_date");
            assertEquals("Eric Johnson", teacherName);
            assertEquals("Maths", subjectName);
        }
    }
}

不仅执行了参数化查询,还验证了 HarperDB 可对非结构化数据执行 JOIN 操作

5.5. 读取用户自定义视图

HarperDB 驱动支持创建用户自定义视图这类虚拟视图适用于无法直接访问表查询的场景(如通过工具使用驱动时)

UserDefinedViews.json 文件中定义视图:

{
  "View_Teacher_Details": {
    "query": "SELECT t.name as teacher_name, t.joining_date as joining_date, s.name as subject_name from Demo.Teacher_Details AS td 
      INNER JOIN Demo.Teacher AS t ON t.id = td.teacher_id INNER JOIN Demo.Subject AS s on s.id = td.subject_id"
  }
}

该视图通过 JOIN 关联所有表获取教师授课详情,默认 schema 为 UserViews

驱动通过连接属性 Location 指定的目录查找 UserDefinedViews.json。使用方式:

@Test
void givenUserDefinedView_whenQueryView_thenSuccess() throws SQLException {
    URL url = ClassLoader.getSystemClassLoader().getResource("UserDefinedViews.json");

    String folderPath = url.getPath().substring(0, url.getPath().lastIndexOf('/'));

    try(Connection connection = getConnection(Map.of("Location", folderPath))) {
        PreparedStatement preparedStatement = connection.prepareStatement("select teacher_name,subject_name"
          + " from UserViews.View_Teacher_Details where subject_name = ?");
        preparedStatement.setString(1, "Science");
        ResultSet resultSet = preparedStatement.executeQuery();
        while(resultSet.next()) {
            assertEquals("Science", resultSet.getString("subject_name"));
        }
    }
}

程序将 UserDefinedViews.json 所在目录路径传递给 getConnection() 方法,驱动即可在 View_Teacher_Details 视图上执行查询,获取所有教授“科学”课程的教师信息。

5.6. 缓存读写

应用常缓存高频访问数据以提升性能。HarperDB 驱动支持将数据缓存到本地磁盘或数据库。

本文示例使用嵌入式 Derby 数据库 作为缓存,也支持选择其他数据库

实现方式:

@Test
void givenAutoCache_whenQuery_thenSuccess() throws SQLException {
    URL url = ClassLoader.getSystemClassLoader().getResource("test.db");
    String folderPath = url.getPath().substring(0, url.getPath().lastIndexOf('/'));
    logger.info("Cache Location:" + folderPath);
    try(Connection connection = getConnection(Map.of("AutoCache", "true", "CacheLocation", folderPath))) {
        PreparedStatement preparedStatement = connection.prepareStatement("select id, name from Demo.Subject");

        ResultSet resultSet = preparedStatement.executeQuery();
        while(resultSet.next()) {
            logger.info("Subject Name:" + resultSet.getString("name"));
        }
    }
}

使用两个连接属性:AutoCacheCacheLocationAutoCache=true 表示所有表查询将自动缓存到 CacheLocation 指定位置。此外,驱动还支持通过 CACHE 语句进行显式缓存

5.7. 更新记录

java.sql.Statement 更新教师授课科目:

@Test
void givenStatement_whenUpdateRecord_thenSuccess() throws SQLException {
    final String UPDATE_SQL = "update Demo.Teacher_Details set subject_id = 2 "
        + "where teacher_id in (2, 5)";
    final String UPDATE_SQL_WITH_SUB_QUERY = "update Demo.Teacher_Details "
        + "set subject_id = (select id from Demo.Subject where name = 'Maths') "
        + "where teacher_id in (select id from Demo.Teacher where name in ('Joe Biden', 'Eric Johnson'))";

    try (Connection connection = getConnection()) {
        Statement statement = connection.createStatement();
        assertDoesNotThrow(() -> statement.execute(UPDATE_SQL));
        assertEquals(2, statement.getUpdateCount());
    }

    try (Connection connection = getConnection()) {
        assertThrows(SQLException.class, () -> connection.createStatement().execute(UPDATE_SQL_WITH_SUB_QUERY));
    }
}

直接使用教师和科目 ID 的更新语句成功执行。但尝试从其他表查询 ID 值时失败,因为 HarperDB 当前不支持子查询

改用 java.sql.PreparedStatement 更新:

@Test
void givenPreparedStatement_whenUpdateRecord_thenSuccess() throws SQLException {
    final String UPDATE_SQL = "update Demo.Teacher_Details set subject_id = ? "
        + "where teacher_id in (?, ?)";

    try (Connection connection = getConnection()) {
        PreparedStatement preparedStatement = connection.prepareStatement(UPDATE_SQL);
        preparedStatement.setInt(1, 1);
        //HarperDB 驱动不支持以下操作
        //Integer[] teacherIds = {4, 5};
        //Array teacherIdArray = connection.createArrayOf(Integer.class.getTypeName(), teacherIds);
        preparedStatement.setInt(2, 4);
        preparedStatement.setInt(3, 5);
        assertDoesNotThrow(() -> preparedStatement.execute());
        assertEquals(2, preparedStatement.getUpdateCount());
    }
}

HarperDB JDBC 驱动不支持创建 java.sql.Array 对象,无法在 in 子句中传递数组参数。因此需多次调用 setInt() 设置教师 ID,这是明显的缺陷,可能带来诸多不便。

5.8. 删除记录

执行 delete 语句删除 Teacher_Details 表记录:

@Test
void givenStatement_whenDeleteRecord_thenSuccess() throws SQLException {
    final String DELETE_SQL = "delete from Demo.Teacher_Details where teacher_id = 6 and subject_id = 3";

    try (Connection connection = getConnection()) {
        Statement statement = connection.createStatement();
        assertDoesNotThrow(() -> statement.execute(DELETE_SQL));
        assertEquals(1, statement.getUpdateCount());
    }
}

java.sql.Statement 成功删除记录。

使用 java.sql.PreparedStatement 的版本:

@Test
void givenPreparedStatement_whenDeleteRecord_thenSuccess() throws SQLException {
    final String DELETE_SQL = "delete from Demo.Teacher_Details where teacher_id = ? and subject_id = ?";

    try (Connection connection = getConnection()) {
        PreparedStatement preparedStatement = connection.prepareStatement(DELETE_SQL);
        preparedStatement.setInt(1, 6);
        preparedStatement.setInt(2, 2);
        assertDoesNotThrow(() -> preparedStatement.execute());
        assertEquals(1, preparedStatement.getUpdateCount());
    }
}

成功执行参数化删除语句。

6. 总结

本文深入探讨了 HarperDB 的 JDBC 支持能力。作为 NoSQL 数据库,HarperDB 通过 JDBC 驱动使 Java 应用能执行 SQL 操作,但部分 SQL 特性尚不支持。

此外,该驱动未完全遵循 JDBC 协议规范,但通过用户自定义视图、临时表、缓存等特性弥补了不足

本文示例代码可在 GitHub 获取。


原始标题:Working With HarperDB and Java