Skip to content

Commit

Permalink
feat: EXPOSED-238 Support EXPLAIN statements (#2022)
Browse files Browse the repository at this point in the history
Add ExplainQuery class that processes EXPLAIN statements for all valid wrapped
statements. The constructor uses 2 new Transaction properties to ensure that the
wrapped statements are not executed and that the internal statements is passed to
the invoking instance.

Add an ExplainResultRow class so that the explain query can be executed and iterated
over in a similar manner to a standard query. This class currently is only good
for printing a readable string version of the results and needs getters.

Add unit tests.
  • Loading branch information
bog-walk authored Mar 5, 2024
1 parent 6bf76ab commit 8ab7414
Show file tree
Hide file tree
Showing 13 changed files with 470 additions and 10 deletions.
29 changes: 29 additions & 0 deletions exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,33 @@ public abstract interface annotation class org/jetbrains/exposed/sql/Experimenta
public abstract interface annotation class org/jetbrains/exposed/sql/ExperimentalKeywordApi : java/lang/annotation/Annotation {
}

public class org/jetbrains/exposed/sql/ExplainQuery : org/jetbrains/exposed/sql/statements/Statement, java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker {
public fun <init> (ZLjava/lang/String;Lorg/jetbrains/exposed/sql/statements/Statement;)V
public fun arguments ()Ljava/lang/Iterable;
public synthetic fun executeInternal (Lorg/jetbrains/exposed/sql/statements/api/PreparedStatementApi;Lorg/jetbrains/exposed/sql/Transaction;)Ljava/lang/Object;
public fun executeInternal (Lorg/jetbrains/exposed/sql/statements/api/PreparedStatementApi;Lorg/jetbrains/exposed/sql/Transaction;)Ljava/sql/ResultSet;
public final fun getAnalyze ()Z
public final fun getOptions ()Ljava/lang/String;
public fun iterator ()Ljava/util/Iterator;
public fun prepareSQL (Lorg/jetbrains/exposed/sql/Transaction;Z)Ljava/lang/String;
}

public final class org/jetbrains/exposed/sql/ExplainQueryKt {
public static final fun explain (Lorg/jetbrains/exposed/sql/Transaction;ZLjava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/ExplainQuery;
public static synthetic fun explain$default (Lorg/jetbrains/exposed/sql/Transaction;ZLjava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/ExplainQuery;
}

public final class org/jetbrains/exposed/sql/ExplainResultRow {
public static final field Companion Lorg/jetbrains/exposed/sql/ExplainResultRow$Companion;
public fun <init> (Ljava/util/Map;[Ljava/lang/Object;)V
public final fun getFieldIndex ()Ljava/util/Map;
public fun toString ()Ljava/lang/String;
}

public final class org/jetbrains/exposed/sql/ExplainResultRow$Companion {
public final fun create (Ljava/sql/ResultSet;Ljava/util/Map;)Lorg/jetbrains/exposed/sql/ExplainResultRow;
}

public abstract class org/jetbrains/exposed/sql/Expression {
public static final field Companion Lorg/jetbrains/exposed/sql/Expression$Companion;
public fun <init> ()V
Expand Down Expand Up @@ -3532,13 +3559,15 @@ public abstract class org/jetbrains/exposed/sql/vendors/FunctionProvider {
public fun <init> ()V
protected final fun appendInsertToUpsertClause (Lorg/jetbrains/exposed/sql/QueryBuilder;Lorg/jetbrains/exposed/sql/Table;Ljava/util/List;Lorg/jetbrains/exposed/sql/Transaction;)V
protected final fun appendJoinPartForUpdateClause (Lorg/jetbrains/exposed/sql/QueryBuilder;Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/sql/Join;Lorg/jetbrains/exposed/sql/Transaction;)V
protected fun appendOptionsToExplain (Ljava/lang/StringBuilder;Ljava/lang/String;)V
protected final fun appendUpdateToUpsertClause (Lorg/jetbrains/exposed/sql/QueryBuilder;Lorg/jetbrains/exposed/sql/Table;Ljava/util/List;Ljava/util/List;Lorg/jetbrains/exposed/sql/Transaction;Z)V
public fun arraySlice (Lorg/jetbrains/exposed/sql/Expression;Ljava/lang/Integer;Ljava/lang/Integer;Lorg/jetbrains/exposed/sql/QueryBuilder;)V
public fun cast (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/IColumnType;Lorg/jetbrains/exposed/sql/QueryBuilder;)V
public fun charLength (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/QueryBuilder;)V
public fun concat (Ljava/lang/String;Lorg/jetbrains/exposed/sql/QueryBuilder;[Lorg/jetbrains/exposed/sql/Expression;)V
public fun day (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/QueryBuilder;)V
public fun delete (ZLorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/Integer;Lorg/jetbrains/exposed/sql/Transaction;)Ljava/lang/String;
public fun explain (ZLjava/lang/String;Ljava/lang/String;Lorg/jetbrains/exposed/sql/Transaction;)Ljava/lang/String;
public fun getDEFAULT_VALUE_EXPRESSION ()Ljava/lang/String;
protected final fun getKeyColumnsForUpsert (Lorg/jetbrains/exposed/sql/Table;[Lorg/jetbrains/exposed/sql/Column;)Ljava/util/List;
protected final fun getUpdateColumnsForUpsert (Ljava/util/List;Ljava/util/List;Ljava/util/List;)Ljava/util/List;
Expand Down
118 changes: 118 additions & 0 deletions exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ExplainQuery.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.jetbrains.exposed.sql

import org.jetbrains.exposed.sql.statements.Statement
import org.jetbrains.exposed.sql.statements.StatementType
import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi
import org.jetbrains.exposed.sql.transactions.TransactionManager
import java.sql.ResultSet

/**
* Represents the SQL query that obtains information about a statement execution plan.
*
* @param analyze Whether the statement whose execution plan is being queried should actually be executed as well.
* @param options String of comma-separated parameters to append after the `EXPLAIN` keyword.
*/
open class ExplainQuery(
val analyze: Boolean,
val options: String?,
private val internalStatement: Statement<*>
) : Iterable<ExplainResultRow>, Statement<ResultSet>(StatementType.SHOW, emptyList()) {
private val transaction
get() = TransactionManager.current()

override fun PreparedStatementApi.executeInternal(transaction: Transaction): ResultSet = executeQuery()

override fun arguments(): Iterable<Iterable<Pair<IColumnType, Any?>>> = internalStatement.arguments()

override fun prepareSQL(transaction: Transaction, prepared: Boolean): String {
val internalSql = internalStatement.prepareSQL(transaction, prepared)
return transaction.db.dialect.functionProvider.explain(analyze, options, internalSql, transaction)
}

override fun iterator(): Iterator<ExplainResultRow> {
val resultIterator = ResultIterator(transaction.exec(this)!!)
return Iterable { resultIterator }.iterator()
}

private inner class ResultIterator(private val rs: ResultSet) : Iterator<ExplainResultRow> {
private val fieldIndex: Map<String, Int> = List(rs.metaData.columnCount) { i ->
rs.metaData.getColumnName(i + 1) to i
}.toMap()

private var hasNext = false
set(value) {
field = value
if (!field) {
rs.statement?.close()
transaction.openResultSetsCount--
}
}

init {
hasNext = rs.next()
}

override fun hasNext(): Boolean = hasNext

override operator fun next(): ExplainResultRow {
if (!hasNext) throw NoSuchElementException()
val result = ExplainResultRow.create(rs, fieldIndex)
hasNext = rs.next()
return result
}
}
}

/**
* A row of data representing a single record retrieved from a database result set about a statement execution plan.
*
* @param fieldIndex Mapping of the field names stored on this row to their index positions.
*/
class ExplainResultRow(
val fieldIndex: Map<String, Int>,
private val data: Array<Any?>
) {
override fun toString(): String = fieldIndex.entries.joinToString { "${it.key}=${data[it.value]}" }

companion object {
/** Creates an [ExplainResultRow] storing all fields in [fieldIndex] with their values retrieved from a [ResultSet]. */
fun create(rs: ResultSet, fieldIndex: Map<String, Int>): ExplainResultRow {
val fieldValues = arrayOfNulls<Any?>(fieldIndex.size)
fieldIndex.values.forEach { index ->
fieldValues[index] = rs.getObject(index + 1)
}
return ExplainResultRow(fieldIndex, fieldValues)
}
}
}

/**
* Creates an [ExplainQuery] using the `EXPLAIN` keyword, which obtains information about a statement execution plan.
*
* **Note:** This operation is not supported by all vendors, please check the documentation.
*
* @param analyze (optional) Whether the statement whose execution plan is being queried should actually be executed as well.
* **Note:** The `ANALYZE` parameter is not supported by all vendors, please check the documentation.
* @param options (optional) String of comma-separated parameters to append after the `EXPLAIN` keyword.
* **Note:** Optional parameters are not supported by all vendors, please check the documentation.
* @param body The statement for which an execution plan should be queried. This can be a `SELECT`, `INSERT`,
* `REPLACE`, `UPDATE` or `DELETE` statement.
* @sample org.jetbrains.exposed.sql.tests.shared.dml.ExplainTests.testExplainWithStatementsNotExecuted
*/
fun Transaction.explain(
analyze: Boolean = false,
options: String? = null,
body: Transaction.() -> Any?
): ExplainQuery {
val query = try {
blockStatementExecution = true
val internalStatement = body() as? Statement<*> ?: explainStatement
checkNotNull(internalStatement) { "A valid query or statement must be provided to the EXPLAIN body." }
ExplainQuery(analyze, options, internalStatement)
} finally {
explainStatement = null
blockStatementExecution = false
}

return query
}
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ fun <T : Table> T.insertIgnore(
fun <T : Table> T.update(where: (SqlExpressionBuilder.() -> Op<Boolean>)? = null, limit: Int? = null, body: T.(UpdateStatement) -> Unit): Int {
val query = UpdateStatement(this, limit, where?.let { SqlExpressionBuilder.it() })
body(query)
return query.execute(TransactionManager.current())!!
return query.execute(TransactionManager.current()) ?: 0
}

/**
Expand All @@ -406,7 +406,7 @@ fun <T : Table> T.update(where: (SqlExpressionBuilder.() -> Op<Boolean>)? = null
fun Join.update(where: (SqlExpressionBuilder.() -> Op<Boolean>)? = null, limit: Int? = null, body: (UpdateStatement) -> Unit): Int {
val query = UpdateStatement(this, limit, where?.let { SqlExpressionBuilder.it() })
body(query)
return query.execute(TransactionManager.current())!!
return query.execute(TransactionManager.current()) ?: 0
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ open class Transaction(

/** The currently executing statement. */
var currentStatement: PreparedStatementApi? = null

/** The current statement for which an execution plan should be queried, but which should never itself be executed. */
internal var explainStatement: Statement<*>? = null

/** Whether this [Transaction] should prevent any statement execution from proceeding. */
internal var blockStatementExecution: Boolean = false

internal val executedStatements: MutableList<PreparedStatementApi> = arrayListOf()
internal var openResultSetsCount: Int = 0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ abstract class Statement<out T>(val type: StatementType, val targets: List<Table

/**
* Executes the SQL statement directly in the provided [transaction] and returns the generated result,
* or `null` if no result was retrieved.
* or `null` if either no result was retrieved or if the transaction blocked statement execution.
*/
fun execute(transaction: Transaction): T? = transaction.exec(this)
fun execute(transaction: Transaction): T? = if (transaction.blockStatementExecution) {
transaction.explainStatement = this
null
} else {
transaction.exec(this)
}

internal fun executeIn(transaction: Transaction): Pair<T?, List<StatementContext>> {
val arguments = arguments()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -714,4 +714,33 @@ abstract class FunctionProvider {
append(" OFFSET $offset")
}
}

/**
* Returns the SQL command that obtains information about a statement execution plan.
*
* @param analyze Whether [internalStatement] should also be executed.
* @param options Optional string of comma-separated parameters specific to the database.
* @param internalStatement SQL string representing the statement to get information about.
* @param transaction Transaction where the operation is executed.
*/
open fun explain(
analyze: Boolean,
options: String?,
internalStatement: String,
transaction: Transaction
): String {
return buildString {
append("EXPLAIN ")
if (analyze) {
append("ANALYZE ")
}
options?.let {
appendOptionsToExplain(it)
}
append(internalStatement)
}
}

/** Appends optional parameters to an EXPLAIN query. */
protected open fun StringBuilder.appendOptionsToExplain(options: String) { append("$options ") }
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ internal object H2FunctionProvider : FunctionProvider() {
) = queryBuilder {
append("LOCATE(\'", substring, "\',", expr, ")")
}

override fun explain(
analyze: Boolean,
options: String?,
internalStatement: String,
transaction: Transaction
): String {
if (options != null) {
transaction.throwUnsupportedException("H2 does not support options other than ANALYZE in EXPLAIN queries.")
}
return super.explain(analyze, null, internalStatement, transaction)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
package org.jetbrains.exposed.sql.vendors

import org.jetbrains.exposed.sql.Expression
import org.jetbrains.exposed.sql.Index
import org.jetbrains.exposed.sql.QueryBuilder
import org.jetbrains.exposed.sql.Sequence
import org.jetbrains.exposed.sql.append
import org.jetbrains.exposed.sql.exposedLogger
import org.jetbrains.exposed.sql.*

internal object MariaDBFunctionProvider : MysqlFunctionProvider() {
override fun nextVal(seq: Sequence, builder: QueryBuilder) = builder {
Expand All @@ -28,6 +23,20 @@ internal object MariaDBFunctionProvider : MysqlFunctionProvider() {
) = queryBuilder {
append("LOCATE(\'", substring, "\',", expr, ")")
}

override fun explain(
analyze: Boolean,
options: String?,
internalStatement: String,
transaction: Transaction
): String {
val sql = super.explain(analyze, options, internalStatement, transaction)
return if (analyze) {
sql.substringAfter("EXPLAIN ")
} else {
sql
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,17 @@ internal object OracleFunctionProvider : FunctionProvider() {
override fun queryLimit(size: Int, offset: Long, alreadyOrdered: Boolean): String {
return (if (offset > 0) " OFFSET $offset ROWS" else "") + " FETCH FIRST $size ROWS ONLY"
}

override fun explain(
analyze: Boolean,
options: String?,
internalStatement: String,
transaction: Transaction
): String {
transaction.throwUnsupportedException(
"EXPLAIN queries are not currently supported for Oracle. Please log a YouTrack feature extension request."
)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,21 @@ internal object PostgreSQLFunctionProvider : FunctionProvider() {
}
return super.delete(ignore, table, where, limit, transaction)
}

override fun explain(
analyze: Boolean,
options: String?,
internalStatement: String,
transaction: Transaction
): String {
return if (analyze && options != null) {
super.explain(false, "ANALYZE TRUE, $options", internalStatement, transaction)
} else {
super.explain(analyze, options, internalStatement, transaction)
}
}

override fun StringBuilder.appendOptionsToExplain(options: String) { append("($options) ") }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,17 @@ internal object SQLServerFunctionProvider : FunctionProvider() {
override fun queryLimit(size: Int, offset: Long, alreadyOrdered: Boolean): String {
return (if (alreadyOrdered) "" else " ORDER BY(SELECT NULL)") + " OFFSET $offset ROWS FETCH NEXT $size ROWS ONLY"
}

override fun explain(
analyze: Boolean,
options: String?,
internalStatement: String,
transaction: Transaction
): String {
transaction.throwUnsupportedException(
"EXPLAIN queries are not currently supported for SQL Server. Please log a YouTrack feature extension request."
)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,19 @@ internal object SQLiteFunctionProvider : FunctionProvider() {
}
return super.delete(ignore, table, where, limit, transaction)
}

override fun explain(
analyze: Boolean,
options: String?,
internalStatement: String,
transaction: Transaction
): String {
if (analyze || options != null) {
transaction.throwUnsupportedException("SQLite does not support ANALYZE or other options in EXPLAIN queries.")
}
val sql = super.explain(false, null, internalStatement, transaction)
return sql.replaceFirst("EXPLAIN ", "EXPLAIN QUERY PLAN ")
}
}

/**
Expand Down
Loading

0 comments on commit 8ab7414

Please sign in to comment.