From e189c735c95bf691e85524c0e871a194735aae27 Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Fri, 10 Apr 2026 10:42:47 -0600 Subject: [PATCH] fix: EXPOSED-987 Add withParentheses to set operations for correct precedence Add a withParentheses parameter (default true) to union(), unionAll(), intersect(), and except() extension functions in both JDBC and R2DBC modules. When true, wraps any preceding set operation in parentheses so the DSL chain order matches the SQL evaluation order. Also fixes Except.copy() which incorrectly returned Intersect. --- exposed-jdbc/api/exposed-jdbc.api | 30 ++++++---- .../exposed/v1/jdbc/SetOperations.kt | 59 ++++++++++++------- .../r2dbc/sql/tests/shared/dml/UnionTests.kt | 38 +++++++++++- exposed-r2dbc/api/exposed-r2dbc.api | 30 ++++++---- .../exposed/v1/r2dbc/SetOperations.kt | 59 ++++++++++++------- .../exposed/v1/tests/shared/dml/UnionTests.kt | 37 +++++++++++- 6 files changed, 189 insertions(+), 64 deletions(-) diff --git a/exposed-jdbc/api/exposed-jdbc.api b/exposed-jdbc/api/exposed-jdbc.api index 856a998314..4a6ce8051e 100644 --- a/exposed-jdbc/api/exposed-jdbc.api +++ b/exposed-jdbc/api/exposed-jdbc.api @@ -53,8 +53,9 @@ public final class org/jetbrains/exposed/v1/jdbc/EmptySizedIterable : java/util/ } public final class org/jetbrains/exposed/v1/jdbc/Except : org/jetbrains/exposed/v1/jdbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V - public fun copy ()Lorg/jetbrains/exposed/v1/jdbc/Intersect; + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun copy ()Lorg/jetbrains/exposed/v1/jdbc/Except; public synthetic fun copy ()Lorg/jetbrains/exposed/v1/jdbc/SizedIterable; public fun getOperationName ()Ljava/lang/String; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; @@ -86,7 +87,8 @@ public final class org/jetbrains/exposed/v1/jdbc/ExposedConnectionImpl : org/jet } public final class org/jetbrains/exposed/v1/jdbc/Intersect : org/jetbrains/exposed/v1/jdbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun copy ()Lorg/jetbrains/exposed/v1/jdbc/Intersect; public synthetic fun copy ()Lorg/jetbrains/exposed/v1/jdbc/SizedIterable; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; @@ -332,7 +334,8 @@ public final class org/jetbrains/exposed/v1/jdbc/SchemaUtils : org/jetbrains/exp public abstract class org/jetbrains/exposed/v1/jdbc/SetOperation : org/jetbrains/exposed/v1/core/AbstractQuery, org/jetbrains/exposed/v1/jdbc/SizedIterable, org/jetbrains/exposed/v1/jdbc/statements/BlockingExecutable { public static final field Companion Lorg/jetbrains/exposed/v1/jdbc/SetOperation$Companion; - public synthetic fun (Ljava/lang/String;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V public fun count ()J public fun empty ()Z public synthetic fun execute (Lorg/jetbrains/exposed/v1/jdbc/JdbcTransaction;)Ljava/lang/Object; @@ -347,6 +350,7 @@ public abstract class org/jetbrains/exposed/v1/jdbc/SetOperation : org/jetbrains public synthetic fun getStatement ()Lorg/jetbrains/exposed/v1/core/statements/Statement; public fun getStatement ()Lorg/jetbrains/exposed/v1/jdbc/SetOperation; protected final fun getTransaction ()Lorg/jetbrains/exposed/v1/jdbc/JdbcTransaction; + public final fun getWrapInParentheses ()Z public fun isAlwaysBatch ()Z public fun iterator ()Ljava/util/Iterator; public fun limit (I)Lorg/jetbrains/exposed/v1/jdbc/SetOperation; @@ -367,10 +371,14 @@ public final class org/jetbrains/exposed/v1/jdbc/SetOperation$Companion { } public final class org/jetbrains/exposed/v1/jdbc/SetOperationsKt { - public static final fun except (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;)Lorg/jetbrains/exposed/v1/jdbc/Except; - public static final fun intersect (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;)Lorg/jetbrains/exposed/v1/jdbc/Intersect; - public static final fun union (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;)Lorg/jetbrains/exposed/v1/jdbc/Union; - public static final fun unionAll (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;)Lorg/jetbrains/exposed/v1/jdbc/UnionAll; + public static final fun except (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;Z)Lorg/jetbrains/exposed/v1/jdbc/Except; + public static synthetic fun except$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/jdbc/Except; + public static final fun intersect (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;Z)Lorg/jetbrains/exposed/v1/jdbc/Intersect; + public static synthetic fun intersect$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/jdbc/Intersect; + public static final fun union (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;Z)Lorg/jetbrains/exposed/v1/jdbc/Union; + public static synthetic fun union$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/jdbc/Union; + public static final fun unionAll (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;Z)Lorg/jetbrains/exposed/v1/jdbc/UnionAll; + public static synthetic fun unionAll$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/jdbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/jdbc/UnionAll; } public final class org/jetbrains/exposed/v1/jdbc/SizedCollection : org/jetbrains/exposed/v1/jdbc/SizedIterable { @@ -407,7 +415,8 @@ public final class org/jetbrains/exposed/v1/jdbc/SizedIterable$DefaultImpls { } public final class org/jetbrains/exposed/v1/jdbc/Union : org/jetbrains/exposed/v1/jdbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun copy ()Lorg/jetbrains/exposed/v1/jdbc/SizedIterable; public fun copy ()Lorg/jetbrains/exposed/v1/jdbc/Union; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; @@ -415,7 +424,8 @@ public final class org/jetbrains/exposed/v1/jdbc/Union : org/jetbrains/exposed/v } public final class org/jetbrains/exposed/v1/jdbc/UnionAll : org/jetbrains/exposed/v1/jdbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun copy ()Lorg/jetbrains/exposed/v1/jdbc/SizedIterable; public fun copy ()Lorg/jetbrains/exposed/v1/jdbc/UnionAll; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; diff --git a/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/v1/jdbc/SetOperations.kt b/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/v1/jdbc/SetOperations.kt index 025c71f12e..80e48e60d9 100644 --- a/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/v1/jdbc/SetOperations.kt +++ b/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/v1/jdbc/SetOperations.kt @@ -20,11 +20,14 @@ import java.sql.ResultSet * Represents an SQL operation that combines the results of multiple queries into a single result. * * @param secondStatement The SQL statement on the right-hand side of the set operator. + * @param wrapInParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. */ sealed class SetOperation( operationName: String, _firstStatement: AbstractQuery<*>, - val secondStatement: AbstractQuery<*> + val secondStatement: AbstractQuery<*>, + val wrapInParentheses: Boolean = true ) : AbstractQuery((_firstStatement.targets + secondStatement.targets).distinct()), BlockingExecutable, SizedIterable { @@ -134,7 +137,11 @@ sealed class SetOperation( it.prepareSQL(this) if (isSubQuery) append(")") } - is SetOperation -> it.prepareSQL(this) + is SetOperation -> { + if (wrapInParentheses) append("(") + it.prepareSQL(this) + if (wrapInParentheses) append(")") + } } } } @@ -193,11 +200,12 @@ sealed class SetOperation( /** Represents an SQL operation that combines all results from two queries, without any duplicates. */ class Union( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("UNION", firstStatement, secondStatement) { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("UNION", firstStatement, secondStatement, wrapInParentheses) { override fun withDistinct(value: Boolean): SetOperation { return if (!value) { - UnionAll(firstStatement, secondStatement).also { + UnionAll(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } } else { @@ -205,7 +213,7 @@ class Union( } } - override fun copy() = Union(firstStatement, secondStatement).also { + override fun copy() = Union(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } } @@ -213,18 +221,19 @@ class Union( /** Represents an SQL operation that combines all results from two queries, with duplicates included. */ class UnionAll( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("UNION ALL", firstStatement, secondStatement) { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("UNION ALL", firstStatement, secondStatement, wrapInParentheses) { override fun withDistinct(value: Boolean): SetOperation { return if (value) { - Union(firstStatement, secondStatement) + Union(firstStatement, secondStatement, wrapInParentheses) } else { this } } - override fun copy() = UnionAll(firstStatement, secondStatement).also { + override fun copy() = UnionAll(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } } @@ -232,9 +241,10 @@ class UnionAll( /** Represents an SQL operation that returns only the common rows from two query results, without any duplicates. */ class Intersect( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("INTERSECT", firstStatement, secondStatement) { - override fun copy() = Intersect(firstStatement, secondStatement).also { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("INTERSECT", firstStatement, secondStatement, wrapInParentheses) { + override fun copy() = Intersect(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } @@ -254,8 +264,9 @@ class Intersect( */ class Except( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("EXCEPT", firstStatement, secondStatement) { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("EXCEPT", firstStatement, secondStatement, wrapInParentheses) { override val operationName: String get() = when { @@ -263,7 +274,7 @@ class Except( else -> "EXCEPT" } - override fun copy() = Intersect(firstStatement, secondStatement).also { + override fun copy() = Except(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } @@ -281,27 +292,35 @@ class Except( /** * Combines all results from [this] query with the results of [other], WITHOUT including duplicates. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.tests.shared.dml.UnionTests.testUnionWithLimit */ -fun AbstractQuery<*>.union(other: Query): Union = Union(this, other) +fun AbstractQuery<*>.union(other: Query, withParentheses: Boolean = true): Union = Union(this, other, withParentheses) /** * Combines all results from [this] query with the results of [other], WITH duplicates included. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.tests.shared.dml.UnionTests.testUnionWithAllResults */ -fun AbstractQuery<*>.unionAll(other: Query): UnionAll = UnionAll(this, other) +fun AbstractQuery<*>.unionAll(other: Query, withParentheses: Boolean = true): UnionAll = UnionAll(this, other, withParentheses) /** * Returns only results from [this] query that are common to the results of [other], WITHOUT including any duplicates. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.tests.shared.dml.UnionTests.testIntersectWithThreeQueries */ -fun AbstractQuery<*>.intersect(other: Query): Intersect = Intersect(this, other) +fun AbstractQuery<*>.intersect(other: Query, withParentheses: Boolean = true): Intersect = Intersect(this, other, withParentheses) /** * Returns only distinct results from [this] query that are NOT common to the results of [other]. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.tests.shared.dml.UnionTests.testExceptWithTwoQueries */ -fun AbstractQuery<*>.except(other: Query): Except = Except(this, other) +fun AbstractQuery<*>.except(other: Query, withParentheses: Boolean = true): Except = Except(this, other, withParentheses) diff --git a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/UnionTests.kt b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/UnionTests.kt index c12d6f46ae..06ec9aa425 100644 --- a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/UnionTests.kt +++ b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/UnionTests.kt @@ -2,6 +2,7 @@ package org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.dml import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.v1.core.QueryBuilder import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.alias import org.jetbrains.exposed.v1.core.eq @@ -27,6 +28,7 @@ import org.jetbrains.exposed.v1.r2dbc.tests.shared.expectException import org.jetbrains.exposed.v1.r2dbc.union import org.jetbrains.exposed.v1.r2dbc.unionAll import org.junit.jupiter.api.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class UnionTests : R2dbcDatabaseTestsBase() { @@ -94,6 +96,20 @@ class UnionTests : R2dbcDatabaseTestsBase() { @Test fun testIntersectWithThreeQueries() { + withCitiesAndUsers(TestDB.ALL_MYSQL) { _, users, _ -> + val usersQuery = users.selectAll() + val sergeyQuery = users.selectAll().where { users.id eq "sergey" } + + // Default withParentheses=true: (UNION ALL) INTERSECT ensures consistent results + usersQuery.unionAll(usersQuery).intersect(sergeyQuery).map { it[users.id] }.toList().apply { + assertEquals(1, size) + assertEquals("sergey", single()) + } + } + } + + @Test + fun testIntersectWithoutParentheses() { withCitiesAndUsers(TestDB.ALL_MYSQL) { _, users, _ -> val usersQuery = users.selectAll() val sergeyQuery = users.selectAll().where { users.id eq "sergey" } @@ -102,18 +118,36 @@ class UnionTests : R2dbcDatabaseTestsBase() { is PostgreSQLDialect, is SQLServerDialect, is MariaDBDialect, is H2Dialect -> true else -> false } - usersQuery.unionAll(usersQuery).intersect(sergeyQuery).map { it[users.id] }.toList().apply { + // withParentheses=false preserves legacy behavior (no wrapping) + usersQuery.unionAll(usersQuery).intersect(sergeyQuery, withParentheses = false).map { it[users.id] }.toList().apply { if (intersectAppliedFirst) { assertEquals(6, size) assertEqualCollections(this, expectedUsers) } else { assertEquals(1, size) - assertEquals("sergey", this.single()) + assertEquals("sergey", single()) } } } } + @Test + fun testSetOperationGeneratesParentheses() { + withCitiesAndUsers { _, users, _ -> + val q1 = users.selectAll().where { users.id eq "andrey" } + val q2 = users.selectAll().where { users.id eq "sergey" } + val q3 = users.selectAll().where { users.id eq "eugene" } + + val withParens = q1.union(q2).intersect(q3) + val sqlWithParens = withParens.prepareSQL(QueryBuilder(false)) + assertTrue(sqlWithParens.startsWith("(")) + + val withoutParens = q1.union(q2).intersect(q3, withParentheses = false) + val sqlWithoutParens = withoutParens.prepareSQL(QueryBuilder(false)) + assertFalse(sqlWithoutParens.startsWith("(")) + } + } + @Test fun testExceptWithTwoQueries() { withCitiesAndUsers(TestDB.ALL_MYSQL) { _, users, _ -> diff --git a/exposed-r2dbc/api/exposed-r2dbc.api b/exposed-r2dbc/api/exposed-r2dbc.api index 4440391a9e..b5b04c8bbf 100644 --- a/exposed-r2dbc/api/exposed-r2dbc.api +++ b/exposed-r2dbc/api/exposed-r2dbc.api @@ -18,8 +18,9 @@ public final class org/jetbrains/exposed/v1/r2dbc/EmptySizedIterable : org/jetbr } public final class org/jetbrains/exposed/v1/r2dbc/Except : org/jetbrains/exposed/v1/r2dbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V - public fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/Intersect; + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/Except; public synthetic fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; public fun getOperationName ()Ljava/lang/String; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; @@ -53,7 +54,8 @@ public final class org/jetbrains/exposed/v1/r2dbc/ExposedR2dbcException : io/r2d } public final class org/jetbrains/exposed/v1/r2dbc/Intersect : org/jetbrains/exposed/v1/r2dbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/Intersect; public synthetic fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; @@ -387,7 +389,8 @@ public final class org/jetbrains/exposed/v1/r2dbc/SchemaUtils : org/jetbrains/ex public abstract class org/jetbrains/exposed/v1/r2dbc/SetOperation : org/jetbrains/exposed/v1/core/AbstractQuery, org/jetbrains/exposed/v1/r2dbc/SizedIterable, org/jetbrains/exposed/v1/r2dbc/statements/SuspendExecutable { public static final field Companion Lorg/jetbrains/exposed/v1/r2dbc/SetOperation$Companion; - public synthetic fun (Ljava/lang/String;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V public fun collect (Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun count (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun empty (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -401,6 +404,7 @@ public abstract class org/jetbrains/exposed/v1/r2dbc/SetOperation : org/jetbrain public synthetic fun getStatement ()Lorg/jetbrains/exposed/v1/core/statements/Statement; public fun getStatement ()Lorg/jetbrains/exposed/v1/r2dbc/SetOperation; protected final fun getTransaction ()Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction; + public final fun getWrapInParentheses ()Z public fun isAlwaysBatch ()Z public fun limit (I)Lorg/jetbrains/exposed/v1/r2dbc/SetOperation; public synthetic fun limit (I)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; @@ -420,10 +424,14 @@ public final class org/jetbrains/exposed/v1/r2dbc/SetOperation$Companion { } public final class org/jetbrains/exposed/v1/r2dbc/SetOperationsKt { - public static final fun except (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;)Lorg/jetbrains/exposed/v1/r2dbc/Except; - public static final fun intersect (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;)Lorg/jetbrains/exposed/v1/r2dbc/Intersect; - public static final fun union (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;)Lorg/jetbrains/exposed/v1/r2dbc/Union; - public static final fun unionAll (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;)Lorg/jetbrains/exposed/v1/r2dbc/UnionAll; + public static final fun except (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;Z)Lorg/jetbrains/exposed/v1/r2dbc/Except; + public static synthetic fun except$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/r2dbc/Except; + public static final fun intersect (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;Z)Lorg/jetbrains/exposed/v1/r2dbc/Intersect; + public static synthetic fun intersect$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/r2dbc/Intersect; + public static final fun union (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;Z)Lorg/jetbrains/exposed/v1/r2dbc/Union; + public static synthetic fun union$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/r2dbc/Union; + public static final fun unionAll (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;Z)Lorg/jetbrains/exposed/v1/r2dbc/UnionAll; + public static synthetic fun unionAll$default (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/r2dbc/Query;ZILjava/lang/Object;)Lorg/jetbrains/exposed/v1/r2dbc/UnionAll; } public final class org/jetbrains/exposed/v1/r2dbc/SizedCollection : org/jetbrains/exposed/v1/r2dbc/SizedIterable { @@ -460,7 +468,8 @@ public final class org/jetbrains/exposed/v1/r2dbc/SizedIterable$DefaultImpls { } public final class org/jetbrains/exposed/v1/r2dbc/Union : org/jetbrains/exposed/v1/r2dbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; public fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/Union; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; @@ -468,7 +477,8 @@ public final class org/jetbrains/exposed/v1/r2dbc/Union : org/jetbrains/exposed/ } public final class org/jetbrains/exposed/v1/r2dbc/UnionAll : org/jetbrains/exposed/v1/r2dbc/SetOperation { - public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;)V + public fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/AbstractQuery;Lorg/jetbrains/exposed/v1/core/AbstractQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; public fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/UnionAll; public synthetic fun withDistinct (Z)Lorg/jetbrains/exposed/v1/core/AbstractQuery; diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/SetOperations.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/SetOperations.kt index 7e3bbb5ab4..8b1c7f7b47 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/SetOperations.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/SetOperations.kt @@ -22,11 +22,14 @@ import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager * Represents an SQL operation that combines the results of multiple queries into a single result. * * @param secondStatement The SQL statement on the right-hand side of the set operator. + * @param wrapInParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. */ sealed class SetOperation( operationName: String, _firstStatement: AbstractQuery<*>, - val secondStatement: AbstractQuery<*> + val secondStatement: AbstractQuery<*>, + val wrapInParentheses: Boolean = true ) : AbstractQuery((_firstStatement.targets + secondStatement.targets).distinct()), SuspendExecutable, SizedIterable { @@ -137,7 +140,11 @@ sealed class SetOperation( it.prepareSQL(this) if (isSubQuery) append(")") } - is SetOperation -> it.prepareSQL(this) + is SetOperation -> { + if (wrapInParentheses) append("(") + it.prepareSQL(this) + if (wrapInParentheses) append(")") + } } } } @@ -187,11 +194,12 @@ sealed class SetOperation( /** Represents an SQL operation that combines all results from two queries, without any duplicates. */ class Union( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("UNION", firstStatement, secondStatement) { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("UNION", firstStatement, secondStatement, wrapInParentheses) { override fun withDistinct(value: Boolean): SetOperation { return if (!value) { - UnionAll(firstStatement, secondStatement).also { + UnionAll(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } } else { @@ -199,7 +207,7 @@ class Union( } } - override fun copy() = Union(firstStatement, secondStatement).also { + override fun copy() = Union(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } } @@ -207,18 +215,19 @@ class Union( /** Represents an SQL operation that combines all results from two queries, with duplicates included. */ class UnionAll( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("UNION ALL", firstStatement, secondStatement) { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("UNION ALL", firstStatement, secondStatement, wrapInParentheses) { override fun withDistinct(value: Boolean): SetOperation { return if (value) { - Union(firstStatement, secondStatement) + Union(firstStatement, secondStatement, wrapInParentheses) } else { this } } - override fun copy() = UnionAll(firstStatement, secondStatement).also { + override fun copy() = UnionAll(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } } @@ -226,9 +235,10 @@ class UnionAll( /** Represents an SQL operation that returns only the common rows from two query results, without any duplicates. */ class Intersect( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("INTERSECT", firstStatement, secondStatement) { - override fun copy() = Intersect(firstStatement, secondStatement).also { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("INTERSECT", firstStatement, secondStatement, wrapInParentheses) { + override fun copy() = Intersect(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } @@ -248,8 +258,9 @@ class Intersect( */ class Except( firstStatement: AbstractQuery<*>, - secondStatement: AbstractQuery<*> -) : SetOperation("EXCEPT", firstStatement, secondStatement) { + secondStatement: AbstractQuery<*>, + wrapInParentheses: Boolean = true +) : SetOperation("EXCEPT", firstStatement, secondStatement, wrapInParentheses) { override val operationName: String get() = when { @@ -257,7 +268,7 @@ class Except( else -> "EXCEPT" } - override fun copy() = Intersect(firstStatement, secondStatement).also { + override fun copy() = Except(firstStatement, secondStatement, wrapInParentheses).also { copyTo(it) } @@ -275,27 +286,35 @@ class Except( /** * Combines all results from [this] query with the results of [other], WITHOUT including duplicates. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.dml.UnionTests.testUnionWithLimit */ -fun AbstractQuery<*>.union(other: Query): Union = Union(this, other) +fun AbstractQuery<*>.union(other: Query, withParentheses: Boolean = true): Union = Union(this, other, withParentheses) /** * Combines all results from [this] query with the results of [other], WITH duplicates included. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.dml.UnionTests.testUnionWithAllResults */ -fun AbstractQuery<*>.unionAll(other: Query): UnionAll = UnionAll(this, other) +fun AbstractQuery<*>.unionAll(other: Query, withParentheses: Boolean = true): UnionAll = UnionAll(this, other, withParentheses) /** * Returns only results from [this] query that are common to the results of [other], WITHOUT including any duplicates. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.dml.UnionTests.testIntersectWithThreeQueries */ -fun AbstractQuery<*>.intersect(other: Query): Intersect = Intersect(this, other) +fun AbstractQuery<*>.intersect(other: Query, withParentheses: Boolean = true): Intersect = Intersect(this, other, withParentheses) /** * Returns only distinct results from [this] query that are NOT common to the results of [other]. * + * @param withParentheses If `true`, wraps any preceding set operation in parentheses to ensure the + * DSL chain order matches the SQL evaluation order. If `false`, relies on SQL operator precedence. * @sample org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.dml.UnionTests.testExceptWithTwoQueries */ -fun AbstractQuery<*>.except(other: Query): Except = Except(this, other) +fun AbstractQuery<*>.except(other: Query, withParentheses: Boolean = true): Except = Except(this, other, withParentheses) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/UnionTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/UnionTests.kt index 92c39eb234..f649f58c7a 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/UnionTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/UnionTests.kt @@ -14,6 +14,7 @@ import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.expectException import org.junit.jupiter.api.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class UnionTests : DatabaseTestsBase() { @@ -81,6 +82,20 @@ class UnionTests : DatabaseTestsBase() { @Test fun testIntersectWithThreeQueries() { + withCitiesAndUsers(TestDB.ALL_MYSQL) { _, users, _ -> + val usersQuery = users.selectAll() + val sergeyQuery = users.selectAll().where { users.id eq "sergey" } + + // Default withParentheses=true: (UNION ALL) INTERSECT ensures consistent results + usersQuery.unionAll(usersQuery).intersect(sergeyQuery).map { it[users.id] }.apply { + assertEquals(1, size) + assertEquals("sergey", single()) + } + } + } + + @Test + fun testIntersectWithoutParentheses() { withCitiesAndUsers(TestDB.ALL_MYSQL) { _, users, _ -> val usersQuery = users.selectAll() val sergeyQuery = users.selectAll().where { users.id eq "sergey" } @@ -89,18 +104,36 @@ class UnionTests : DatabaseTestsBase() { is PostgreSQLDialect, is SQLServerDialect, is MariaDBDialect, is H2Dialect -> true else -> false } - usersQuery.unionAll(usersQuery).intersect(sergeyQuery).map { it[users.id] }.apply { + // withParentheses=false preserves legacy behavior (no wrapping) + usersQuery.unionAll(usersQuery).intersect(sergeyQuery, withParentheses = false).map { it[users.id] }.apply { if (intersectAppliedFirst) { assertEquals(6, size) assertEqualCollections(this, expectedUsers) } else { assertEquals(1, size) - assertEquals("sergey", this.single()) + assertEquals("sergey", single()) } } } } + @Test + fun testSetOperationGeneratesParentheses() { + withCitiesAndUsers { _, users, _ -> + val q1 = users.selectAll().where { users.id eq "andrey" } + val q2 = users.selectAll().where { users.id eq "sergey" } + val q3 = users.selectAll().where { users.id eq "eugene" } + + val withParens = q1.union(q2).intersect(q3) + val sqlWithParens = withParens.prepareSQL(QueryBuilder(false)) + assertTrue(sqlWithParens.startsWith("(")) + + val withoutParens = q1.union(q2).intersect(q3, withParentheses = false) + val sqlWithoutParens = withoutParens.prepareSQL(QueryBuilder(false)) + assertFalse(sqlWithoutParens.startsWith("(")) + } + } + @Test fun testExceptWithTwoQueries() { withCitiesAndUsers(TestDB.ALL_MYSQL) { _, users, _ ->