5
\$\begingroup\$

I needed something to map named SQL parameters into indices for JDBC queries so I wrote the ParameterMap class that takes an SQL query, searchers for parameters prefixed with a colon : and while replacing each instance it counts their indices adding them to the map as a list associated with each name. There is also an extension function forEach that passes one index at a time to the lambda where the actual parameters are set.


See also the follow-up.


class ParameterMap(sql: String) {
    private val parameterRegex = Regex("(:([a-z0-9_]+))", RegexOption.IGNORE_CASE)
    private val map = mutableMapOf<String, List<Int>>()
    val sqlWithQuestionMarks: String

    init {
        var index = 1 // <-- JDBC starts counting with 1
        sqlWithQuestionMarks = parameterRegex.replace(sql) { match ->
            val key = match.groups[2]!!.value.lowercase()
            map.computeIfPresent(key) { _, indexes -> indexes.plus(index++) }
            map.computeIfAbsent(key) { mutableListOf(index++) }
            return@replace "?"
        }
    }

    operator fun get(name: String) = map.getValue(name.lowercase())
    override fun toString() = sqlWithQuestionMarks

}

fun ParameterMap.forEach(name: String, block: (Int) -> Unit) {
    for (index in this[name]) block(index)
}

Example:

select *
from person
where
    (nullif(:first_name, '') is null or first_name = :first_name) and
    (nullif(:last_name, '') is null or last_name = :last_name)
limit 100;
val parameterMap = ParameterMap(File("sql/select.sql").readText())
val selectStatement = sqliteConnection.prepareStatement(parameterMap.sqlWithQuestionMarks)
parameterMap.forEach("first_name") {
    selectStatement.setString(it, "John")
}
parameterMap.forEach("last_name") {
    selectStatement.setString(it, "Doe")
}

What do you think of this helper? Can it be made more Kotlin-ish?

\$\endgroup\$
1
  • 1
    \$\begingroup\$ I don't know how to put this into Kotlin-ish, but I noticed that there's nothing to protect the programmer from doing something dumb, like forgetting that a placeholder wasn't replaced. If you have some way to keep a list of what wasn't replaced, then you can throw an IllegalStateException or something when there's un-replaced placeholders. This way, you prevent sending invalid SQL into the database. SQLite3 seems to support the :placeholder style natively, so, you will see weird side effects from the SQLite3 side. \$\endgroup\$ Commented Aug 31, 2023 at 23:28

3 Answers 3

6
\$\begingroup\$

Name of the class doesn't indicate its purpose.

The class is called "ParameterMap" but its use-case (filling in values in a query) doesn't need to know about it having a map somewhere inside - it's an implementation detail.

Overriding toString is misleading for a similar reason: if I want to print a "ParameterMap" I expect to see some sort of a map, not a query.


But what really is the use-case here?

We have an sql statement with placeholders that need to be filled with actual values. Let's write down the use-case in a concise manner:

File("sql/select.sql").readText().fill(
    "first_name" to "John",
    "last_name" to "Doe",
)

So we really only need a function with an interface like this:

String.fill(vararg args: Pair<String, String>): PreparedStatement

There is no need to expose sqlWithQuestionMarks and the get operator, since they are irrelevant to the end-user, and therefore no need for a class either.

Now that we look at the problem this way, we can find a one-line solution:

String.executeWith(connection: Connection, vararg args: Pair<String, String>): ResultSet =
    connection.createStatement().executeQuery(this.also { args.forEach { this.replace(":${it.first}", it.second) } })

Nitpicks:

forEach doesn't need to be an extension function since it's defined in the same file as the class it's extending.

indexes.plus(index++) uses an operator: indexes + index++

Plural of "index" is "indices".

val key = match.groups[2]!!.value.lowercase() - this line could use a comment to explain what .groups[2]!! gives us.

\$\endgroup\$
5
  • \$\begingroup\$ The thing with the ugly PreparedStatement is that it requires a connection to be created (at least I didn't find another way to do this) and it needs be saved for later as it's a huge bottle-neck if you create it on every call, and it needs to be closed at some point. I discovered it the hard way in my other question where I insert hundreds of thousands of rows. I also didn't want to provide my own versions of each setX function as there are too many of them. It's difficult to find a nice balance here, but I see what you mean and I like this direction. \$\endgroup\$
    – t3chb0t
    Commented Aug 31, 2023 at 5:01
  • \$\begingroup\$ I think there's one issue with this solution that I wouldn't like to deal with so I prefer to let JDBC handle it with its setX methods and this is to figure out whether you need quotes for the parameters or not when you directly replace parameters in the SQL itself. JDBC really sucks in every way :P Whatever you try it'll bite you somehow. \$\endgroup\$
    – t3chb0t
    Commented Aug 31, 2023 at 5:26
  • \$\begingroup\$ @t3chb0t if you're worried about excessive connection recreations, my 2nd solution deals with it by taking an existing connection as a parameter. \$\endgroup\$ Commented Aug 31, 2023 at 10:17
  • \$\begingroup\$ Not the connections, the statements. Apparently they're pretty expensive. But I already have another experimental idea and I think I'll ask a follow up later. \$\endgroup\$
    – t3chb0t
    Commented Aug 31, 2023 at 10:27
  • \$\begingroup\$ As much as I'm personally a fan of indices, either plural form is acceptable: grammarist.com/usage/indexes-indices \$\endgroup\$ Commented Sep 4, 2023 at 19:23
4
\$\begingroup\$

commenting

        var index = 1 // <-- JDBC starts counting with 1

Thank you for the one-origin reminder, that's a helpful comment.

            val key = match.groups[2]!!.value.lowercase()

Hmmm, today I learned about kotlin's !! double bang type conversion idiom, cool.

EDIT

@Tenfour04's comment advises that in production code the !! anti-pattern is frowned upon.

    ... parameterRegex = Regex("(:([a-z0-9_]+))", ...)

I found that expression needlessly complex. Why are we capturing the : colon? Wouldn't ":([a-z0-9_]+)" suffice? Please add a comment explaining why two capturing groups is useful here.

BTW this regex isn't exactly bullet proof, but I feel it is Good Enough. For example WHERE text LIKE "foo:bar%" would also match, whatever.


typical call

parameterMap.forEach("first_name") {
    selectStatement.setString(it, "John")
}
parameterMap.forEach("last_name") {
    selectStatement.setString(it, "Doe")
}

Thank you for offering an instructive example. The first two lines look good. But these two stanzas feel clunky.

If that's the functionality that callers will usually want, maybe delete or make private the forEach function, and instead offer a compact "set these names using these values" method.


This codebase achieves its design goals.

I would be willing to delegate or accept maintenance tasks on it.

\$\endgroup\$
2
  • 1
    \$\begingroup\$ I put the index comment there after it blew I my face when I started with a 0 even though I used it before in other places LOL. The idea behind the forEach was to make it as much JDBC independent as possible and to delegate all the work to it somehow as JDBC is not a user-friendly API at and everything needs to be either cached or closed somehow, requires other instances to create things like a connection to create a prepared statement or it has like dozens of APIs that you don't want to rewrite yourself with a named parameter. \$\endgroup\$
    – t3chb0t
    Commented Aug 31, 2023 at 4:53
  • 2
    \$\begingroup\$ Double bang isn't an idiom...its frowned upon in idiomatic usage of the language. For example, when retrieving from a map and asserting a non-null value (basically asserting the value is known to be in the map), it is preferred to use getValue() instead of []!! as it makes the intent clear and it throws a much clearer and more specfic Exception than an NPE if your assertion was false. In other cases, you can use ?: error() to ensure a false assertion has a relevant exception message. Double bang is mostly useful in quick snippets and tests. \$\endgroup\$
    – Tenfour04
    Commented Sep 8, 2023 at 17:27
1
+50
\$\begingroup\$

This:

map.computeIfPresent(key) { _, indexes -> indexes.plus(index++) }
map.computeIfAbsent(key) { mutableListOf(index++) }

can be simplified to:

map[key] = map.getOrElse(key, ::emptyList) + index++

The "compute" functions come from the Java standard library and are not inlined, so they have to allocate the lambdas. If you want to make your code "more Kotlinish", you probably want to avoid JVM-exclusive functions like these.

I also noticed you are inconsistent about whether you require the caller to ensure the key has only lowercase letters. get() takes care of it, but forEach will silently fail.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.