本文转自 https://skyblond.info/archives/751.html
最近从滴滴辞职,为期 5 天的暑假正式开始了,寻思着做一点有意义的事情提升一下自己。遂决定自己写一套专门用于复杂查询的通联日志管理系统,数据库选用了 PostgreSQL,该数据库可以直接对 Json 类型的数据进行高级查询,然而 Ktorm 框架并不支持此功能,因此本文将记述为该框架进行拓展的过程。
需求 假设数据库中有一表 qso_infos
用于存储通联日志,其中 qsl_info
字段表示 QSL 相关事宜的记录,该字段为 jsonb
类型,样例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 { "lotw" : { "uploaded" : true , "uploadDate" : "2020-06-20" , "comfirmed" : true , "comfireDate" : "2020-07-21" }, "card" : { "sent" : { "sent" : false , "required" : true , "sentDate" : null , "requiredDate" : "2020-06-21" , "via" : "bureau" }, "received" : { "received" : false , "required" : true , "receivedDate" : null , "requiredDate" : "2020-06-22" , "via" : "direct" } }, "comment" : "因为COVID-19推迟寄送QSL卡片" }
现在需要查询所有 LoTW 未上传的记录,应当如何利用 Ktorm 在数据库端完成?
如果需要查询所有 comment 为空的记录,应当如何利用 Ktorm 在数据库端完成?
分析 如果直接编写 SQL 的话,应当按如下编写:
1 2 SELECT * FROM qso_infos WHERE qsl_info->'lotw' ->>'uploaded' = 'false' ;SELECT * FROM qso_infos WHERE qsl_info->>'comment' = '' ;
其中第一句还可以这样写:
1 SELECT * FROM qso_infos WHERE (qsl_info->>'lotw' )::json ->>'uploaded' = 'false' ;
第一种写法用到了两个不同的运算符:->
是作为 Json 取出,而 ->>
则是作为字符串取出。需要注意的是对于 Json Object 这两个运算符是根据输入的字符串作为键去取值,而对于 Json Array 则是按照输入的整形作为从 0 开始的索引去取值。为了最大兼容性的考虑,本文将同时实现每个运算符的两个形式。
对于第二种写法,虽然只需要自定义一个运算符即可,但还需要将转换为 Json 作为一个额外的运算符实现。本文也将实现该运算符。
实现 SQL 类型扩展 为了实现对 Json 的访问,首先应当定义 json
和 jsonb
两个 SQL 数据类型。这里我没有使用 ktorm 的模块,而是基于 Gson 写了一个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 package info.skyblond.jinn.extensionimport com.google.gson.Gsonimport org.ktorm.schema.BaseTableimport org.ktorm.schema.Columnimport org.ktorm.schema.SqlTypeimport org.ktorm.schema.TypeReferenceimport java.lang.reflect.Typeimport java.sql.PreparedStatementimport java.sql.ResultSetimport java.sql.Typesclass JsonbSqlType <T : Any > (private val type: Type) : SqlType<T>(Types.JAVA_OBJECT, typeName = "jsonb" ) { private val gson = Gson() override fun doSetParameter (ps: PreparedStatement , index: Int , parameter: T ) { ps.setString(index, gson.toJson(parameter)) } override fun doGetResult (rs: ResultSet , index: Int ) : T? { val json = rs.getString(index) return if (json.isNullOrBlank()) { null } else { gson.fromJson(json, type) } } } fun <C : Any> BaseTable<*> .jsonb ( name: String , typeReference: TypeReference <C > ) : Column<C> { return registerColumn(name, JsonbSqlType(typeReference.referencedType)) } internal class JsonSqlType <T : Any > (private val type: Type) : SqlType<T>(Types.JAVA_OBJECT, typeName = "json" ) { private val gson = Gson() override fun doSetParameter (ps: PreparedStatement , index: Int , parameter: T ) { ps.setString(index, gson.toJson(parameter)) } override fun doGetResult (rs: ResultSet , index: Int ) : T? { val json = rs.getString(index) return if (json.isNullOrBlank()) { null } else { gson.fromJson(json, type) } } } fun <C : Any> BaseTable<*> .json ( name: String , typeReference: TypeReference <C > ) : Column<C> { return registerColumn(name, JsonSqlType(typeReference.referencedType)) }
关于代码就不多说了,具体可以参考 Ktorm 文档 - 定义表结构 - 扩展更多的类型 。这里最重要的一点就是要使用限制比较宽松的框架进行 Json 转换,一开始我试图使用 kotlinx 的 serialization 进行,于是进行 Json 转换的时候就需要一个 KSerializer
对象才能工作,而该对象的获取渠道是 SomeClass.serializer()
,其中 SomeClass
需要被 @kotlinx.serialization.Serializable
注解。之后进行 Json 操作时为了最大的兼容性,通常都是认为操作的结果是 Any
而非特定一个类,那么问题就来了:Any
类似 Java 的 Object
,是万物之父,而所有被 @Serializable
注解的类,可没有一个统一的父类。因此这样的架构在后续实现运算符的时候就会非常难受。最起码也是要实现了 Java 的 Serializable
接口(或其他框架的统一的接口)。
现在有了 Json 数据类型,接下来我们就可以实现运算符了。
自定义运算符 关于自定义运算符,这里同样不多赘述,详细指导可以参考 Ktorm 文档 - 运算符 - 自定义运算符 。拓展的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 package info.skyblond.jinn.extensionimport org.ktorm.expression.ArgumentExpressionimport org.ktorm.expression.ScalarExpressionimport org.ktorm.schema.ColumnDeclaringimport org.ktorm.schema.IntSqlTypeimport org.ktorm.schema.SqlTypeimport org.ktorm.schema.VarcharSqlTypedata class AsJsonExpression ( val left: ScalarExpression<*>, override val sqlType: SqlType<Any> = JsonSqlType(Any::class .java ), override val isLeafNode: Boolean = false , override val extraProperties: Map<String, Any> = mapOf(), val alreadyJson: Boolean = false ) : ScalarExpression<Any>() fun ColumnDeclaring<*> .asJson (alreadyJson: Boolean = false ) : AsJsonExpression { return AsJsonExpression(asExpression(), alreadyJson = alreadyJson) } data class JsonAccessExpression <T : Any > ( val left: AsJsonExpression, val right: ArgumentExpression<T>, override val sqlType: SqlType<Any> = JsonSqlType(Any::class .java ), override val isLeafNode: Boolean = false , override val extraProperties: Map<String, Any> = mapOf() ) : ScalarExpression<Any>() operator fun AsJsonExpression.get (param: String ) : AsJsonExpression { return JsonAccessExpression(this , ArgumentExpression(param, VarcharSqlType)).asJson(true ) } operator fun AsJsonExpression.get (param: Int ) : AsJsonExpression { return JsonAccessExpression(this , ArgumentExpression(param, IntSqlType)).asJson(true ) } data class JsonAccessAsTextExpression <T : Any > ( val left: AsJsonExpression, val right: ArgumentExpression<T>, override val sqlType: SqlType<String> = VarcharSqlType, override val isLeafNode: Boolean = false , override val extraProperties: Map<String, Any> = mapOf() ) : ScalarExpression<String>() fun AsJsonExpression.getAsString (param: String ) : JsonAccessAsTextExpression<String> { return JsonAccessAsTextExpression(this , ArgumentExpression(param, VarcharSqlType)) } fun AsJsonExpression.getAsString (param: Int ) : JsonAccessAsTextExpression<Int > { return JsonAccessAsTextExpression(this , ArgumentExpression(param, IntSqlType)) }
在这里我们实现了 AsJsonExpression
,该表达式将前面的语句转换成 Json 类型;JsonAccessExpression
,该表达式将对 Json 类型的数据进行 ->
运算;JsonAccessAsTextExpression
,该表达式将对 Json 类型的数据进行 ->>
运算。
在 Ktorm 中对于数据的面向对象式筛选,实际上是基于面向对象的写法产生一个表达式树。该树中的每一个 Expression
表示一个运算或参数,最终将被解析为一条 SQL 语句。
值得注意的是在 AsJsonExpression
的实现中只有 left
,原本是打算将其作为 AsDataTypeExpression
,然后 right
作为一个 SqlType
来实现更通用的功能的,但是这样一来后面的实现将无法保证只对 Json 类型的语句进行访问:JsonSqlType
要能够适配所有情况,其类型必定是 SqlType
,这样一来将无法区分哪些表达式是 Json,哪些不是。当然你也可以额外在加一个类型:AsDataTypeExpression
,T 作为 SqlType
,而 U 直接存储 JsonSqlType
,但是就本文而言,还是单独搞一个 AsJsonExpression
来的最实在。
关于 asJson()
函数,设置 alreadyJson
的目的就是后面对于 ->
运算符,其运算结果本身就是 Json,而对于编译器来说则是 JsonAccessExpression
类型,不能应用 AsJsonExpression
的访问操作。因此这里只是单纯的为了让编译器开心,当进行 ->
访问时,除了产生一个访问表达式之外,还在外面报一个 AsJsonExpression
,这样对于编译器来说就是合法的 Json 类型了,而在生成 SQL 语句时通过判断 alreadyJson
字段可以跳过本次类型转换。
最后为了写起来更舒爽,对于作为 Json Object 取出的 ->
运算,我重载了 kotlin 内置的 get 方法,这样就可以通过 asJson()[keyName]
的形式进行访问了。
而只进行到此还是不够全面,目前 ktorm 还不能正确翻译这些表达式。下一步将进行方言扩展。
扩展方言 既然说扩展方言,就是说我们并不像替代原有的 PostgreSQL 方言,而 Ktorm 的作者也贴心的将方言实现类加了 open 关键字,这样我们就可以自由的进行扩展了。根据 Ktorm 文档 - 运算符 - 自定义运算符 ,扩展的方言应当覆盖对未知表达式的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 package info.skyblond.jinn.extensionimport org.ktorm.database.Databaseimport org.ktorm.expression.SqlExpressionimport org.ktorm.expression.SqlFormatterimport org.ktorm.support.postgresql.PostgreSqlDialectimport org.ktorm.support.postgresql.PostgreSqlFormatteropen class MyPostgreSqlDialect : PostgreSqlDialect () { override fun createSqlFormatter (database: Database , beautifySql: Boolean , indentSize: Int ) : SqlFormatter { return MyPostgreSqlFormatter(database, beautifySql, indentSize) } } class MyPostgreSqlFormatter (database: Database, beautifySql: Boolean , indentSize: Int ) : PostgreSqlFormatter(database, beautifySql, indentSize) { override fun visitUnknown (expr: SqlExpression ) : SqlExpression { return when (expr) { is AsJsonExpression -> { if (expr.left.removeBrackets) { visit(expr.left) } else { write("(" ) visit(expr.left) removeLastBlank() write(") " ) } if (!expr.alreadyJson) { removeLastBlank() write("::json " ) } expr } is JsonAccessAsTextExpression<*> -> { visit(expr.left) write("->> " ) visit(expr.right) expr } is JsonAccessExpression<*> -> { visit(expr.left) write("-> " ) visit(expr.right) expr } else -> { super .visitUnknown(expr) } } } }
在遇到没有见过的表达式时,将回调父类的处理函数,而这个函数的默认行为就是抛异常。这里在产生 SQL 语句的时候有一些细节要注意。
对于转换为 Json 的语句,需要时应当增加括号来保证运算优先级的正确性。而对于 Json 的访问,其左值一定是 Json,而右值则一定是数字或字符串类型的参数,因此左右皆无需增加括号。
最后我们需要在连接数据库的时候指定该方言。
结果 首先连接数据库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 val pgDataSource = PGDataSource()pgDataSource.serverName = "localhost" pgDataSource.portNumber = 5432 pgDataSource.user = "logbook" pgDataSource.password = "***" pgDataSource.databaseName = "logbook" val config = HikariConfig()config.driverClassName = "com.impossibl.postgres.jdbc.PGDataSource" config.dataSource = pgDataSource config.addDataSourceProperty("cachePrepStmts" , "true" ) config.addDataSourceProperty("prepStmtCacheSize" , "250" ) config.addDataSourceProperty("prepStmtCacheSqlLimit" , "2048" ) val hikariDataSource = HikariDataSource(config)val database = Database.connect(hikariDataSource, dialect = MyPostgreSqlDialect())
注意最后在连接数据库的时候指定了我们自己实现的方言。之后在定义完表和实体类之后进行查询:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 database.sequenceOf(QsoInfos).filter { it.qslInfo.asJson(true )["lotw" ].getAsString("uploaded" ) eq "true" }.forEach { println(it) } database.sequenceOf(QsoInfos).filter { it.qslInfo.asJson(true )["card" ]["sent" ].getAsString("sent" ) eq "false" }.forEach { println(it) } database.sequenceOf(QsoInfos).filter { it.qslInfo.asJson(true ).getAsString("comment" ) notEq "" }.forEach { println(it) } database.sequenceOf(QsoInfos).filter { it.qslInfo.asJson(true ).getAsString("card" ).asJson().getAsString("sent" ).asJson().getAsString("sent" ) notEq "true" }.forEach { println(it) }
对应的分别产生了如下 SQL:
1 2 3 4 5 6 7 8 9 10 11 DEBUG org.ktorm.database - SQL: select ... from qso_infos where ((qso_infos.qsl_info -> ?) ->> ?) = ? DEBUG org.ktorm.database - Parameters: [lotw(varchar), uploaded(varchar), true(varchar)] DEBUG org.ktorm.database - SQL: select ... from qso_infos where (((qso_infos.qsl_info -> ?) -> ?) ->> ?) = ? DEBUG org.ktorm.database - Parameters: [card(varchar), sent(varchar), sent(varchar), false(varchar)] DEBUG org.ktorm.database - SQL: select ... from qso_infos where (qso_infos.qsl_info ->> ?) <> ? DEBUG org.ktorm.database - Parameters: [comment(varchar), (varchar)] DEBUG org.ktorm.database - SQL: select ... from qso_infos where (((qso_infos.qsl_info ->> ?)::json ->> ?)::json ->> ?) <> ? DEBUG org.ktorm.database - Parameters: [card(varchar), sent(varchar), sent(varchar), true(varchar)]
看起来符合预期,程序也没有报错。至此可以算是完美决绝问题。
- 全文完 -