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, _ ->