Skip to content

Commit a669c7d

Browse files
authored
Merge pull request #904 from phdoerfler/topic/union-order-coverage
Add union-branch ordering coverage on a schema-qualified table
2 parents 1dddb7f + 3264c3c commit a669c7d

10 files changed

Lines changed: 284 additions & 0 deletions

File tree

modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ final class TreeSuite extends DoobieMSSqlDatabaseSuite with SqlTreeSuite {
233233
lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlTreeMapping[IO]
234234
}
235235

236+
final class UnionOrderSuite extends DoobieMSSqlDatabaseSuite with SqlUnionOrderSuite {
237+
lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlUnionOrderMapping[IO]
238+
}
239+
236240
final class UnionsSuite extends DoobieMSSqlDatabaseSuite with SqlUnionSuite {
237241
lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlUnionsMapping[IO]
238242
}

modules/doobie-oracle/src/test/resources/scripts/01_create_user.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,10 @@ ALTER SESSION SET CONTAINER=FREEPDB1;
33
CREATE USER TEST IDENTIFIED BY test QUOTA UNLIMITED ON USERS;
44

55
GRANT CONNECT, RESOURCE TO TEST;
6+
7+
-- A schema is a user in Oracle, so a schema-qualified fixture needs a second user. The fixtures
8+
-- all run as TEST, so TEST needs ANY rights to populate it. CREATE ANY INDEX is required as well
9+
-- as CREATE ANY TABLE, because a PRIMARY KEY creates an index in the other schema.
10+
CREATE USER QUALIFIED IDENTIFIED BY test QUOTA UNLIMITED ON USERS;
11+
12+
GRANT CREATE ANY TABLE, CREATE ANY INDEX, INSERT ANY TABLE, SELECT ANY TABLE TO TEST;

modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ final class NullOrderingSuite extends DoobieOracleDatabaseSuite with SqlNullOrde
205205
lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlNullOrderingMapping[IO]
206206
}
207207

208+
final class UnionOrderSuite extends DoobieOracleDatabaseSuite with SqlUnionOrderSuite {
209+
lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlUnionOrderMapping[IO]
210+
}
211+
208212
final class Paging1Suite extends DoobieOracleDatabaseSuite with SqlPaging1Suite {
209213
lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlPaging1Mapping[IO]
210214
}

modules/doobie-pg/src/test/scala/DoobiePgSuites.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ final class TableNameSuite extends DoobiePgDatabaseSuite with SqlTableNameSuite
225225
lazy val mapping = new DoobiePgTestMapping(transactor) with SqlQualifiedNamesMapping[IO]
226226
}
227227

228+
final class UnionOrderSuite extends DoobiePgDatabaseSuite with SqlUnionOrderSuite {
229+
lazy val mapping = new DoobiePgTestMapping(transactor) with SqlUnionOrderMapping[IO]
230+
}
231+
228232
final class RecursiveInterfacesSuite
229233
extends DoobiePgDatabaseSuite
230234
with SqlRecursiveInterfacesSuite {

modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,10 @@ final class TableNameSuite extends SkunkDatabaseSuite with SqlTableNameSuite {
230230
lazy val mapping = new SkunkTestMapping(pool) with SqlQualifiedNamesMapping[IO]
231231
}
232232

233+
final class UnionOrderSuite extends SkunkDatabaseSuite with SqlUnionOrderSuite {
234+
lazy val mapping = new SkunkTestMapping(pool) with SqlUnionOrderMapping[IO]
235+
}
236+
233237
final class RecursiveInterfacesSuite
234238
extends SkunkDatabaseSuite
235239
with SqlRecursiveInterfacesSuite {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
2+
// Copyright (c) 2016-2025 Grackle Contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package grackle.sql.test
17+
18+
import grackle._
19+
import grackle.Predicate._
20+
import grackle.Query.{Binding, Limit, OrderBy, OrderSelection, OrderSelections}
21+
import grackle.QueryCompiler.{Elab, SelectElaborator}
22+
import grackle.Value.{AbsentValue, EnumValue, IntValue, NullValue}
23+
import grackle.syntax._
24+
25+
// Covers a gap that predates issue #342 entirely: no fixture anywhere combines a union-typed
26+
// field with both `order` and `limit`, which is the only condition under which
27+
// SqlUnion.addFilterOrderByOffsetLimit pushes ordering into individual union branches
28+
// (SqlMapping.scala: `branchOrderBy = limit.flatMap(_ => orderBy)`). On backends where
29+
// encapsulateUnionBranch does real work (MSSQL), this is also the only path that exercises it.
30+
// The table is schema-qualified on every backend, so union-branch encapsulation is exercised
31+
// against a qualified name rather than only on MSSQL. The name is "union_order_entities", not
32+
// "entities" - that name is already taken by testdata/{pg,mssql}/interfaces.sql on both backends.
33+
trait SqlUnionOrderMapping[F[_]] extends SqlTestMapping[F] {
34+
35+
object entities extends TableDef("qualified.union_order_entities") {
36+
val id = col("id", text)
37+
val entityType = col("entity_type", text)
38+
val name = col("name", text)
39+
}
40+
41+
val schema =
42+
schema"""
43+
type Query {
44+
entities(order: Order, limit: Int): [Entity!]!
45+
}
46+
type ItemA {
47+
id: String!
48+
name: String!
49+
}
50+
type ItemB {
51+
id: String!
52+
name: String!
53+
}
54+
union Entity = ItemA | ItemB
55+
enum Order {
56+
ASC
57+
DESC
58+
}
59+
"""
60+
61+
val QueryType = schema.ref("Query")
62+
val ItemAType = schema.ref("ItemA")
63+
val ItemBType = schema.ref("ItemB")
64+
val EntityType = schema.ref("Entity")
65+
66+
val typeMappings =
67+
List(
68+
ObjectMapping(
69+
tpe = QueryType,
70+
fieldMappings = List(
71+
SqlObject("entities")
72+
)
73+
),
74+
SqlUnionMapping(
75+
tpe = EntityType,
76+
discriminator = entityTypeDiscriminator,
77+
fieldMappings = List(
78+
SqlField("id", entities.id, key = true, hidden = true),
79+
SqlField("name", entities.name, hidden = true),
80+
SqlField("entityType", entities.entityType, discriminator = true, hidden = true)
81+
)
82+
),
83+
ObjectMapping(
84+
tpe = ItemAType,
85+
fieldMappings = List(
86+
SqlField("id", entities.id, key = true),
87+
SqlField("name", entities.name)
88+
)
89+
),
90+
ObjectMapping(
91+
tpe = ItemBType,
92+
fieldMappings = List(
93+
SqlField("id", entities.id, key = true),
94+
SqlField("name", entities.name)
95+
)
96+
)
97+
)
98+
99+
object entityTypeDiscriminator extends SqlDiscriminator {
100+
def discriminate(c: Cursor): Result[Type] =
101+
for {
102+
et <- c.fieldAs[String]("entityType")
103+
} yield et match {
104+
case "ItemA" => ItemAType
105+
case "ItemB" => ItemBType
106+
}
107+
108+
def narrowPredicate(subtpe: Type): Result[Predicate] = {
109+
def mkPredicate(tpe: String): Result[Predicate] =
110+
Eql(EntityType / "entityType", Const(tpe)).success
111+
112+
subtpe match {
113+
case ItemAType => mkPredicate("ItemA")
114+
case ItemBType => mkPredicate("ItemB")
115+
case _ => Result.internalError(s"Invalid discriminator: $subtpe")
116+
}
117+
}
118+
}
119+
120+
sealed trait ListOrder {
121+
def ascending: Boolean
122+
}
123+
object ListOrder {
124+
case object Ascending extends ListOrder { def ascending = true }
125+
case object Descending extends ListOrder { def ascending = false }
126+
127+
def fromGraphQLString(s: String): Option[ListOrder] =
128+
s.trim.toUpperCase match {
129+
case "ASC" => Some(Ascending)
130+
case "DESC" => Some(Descending)
131+
case _ => None
132+
}
133+
}
134+
135+
object OrderValue {
136+
def unapply(ev: EnumValue): Option[ListOrder] =
137+
ListOrder.fromGraphQLString(ev.name)
138+
}
139+
140+
def mkLimit(query: Query, limit: Value): Result[Query] =
141+
limit match {
142+
case AbsentValue | NullValue => query.success
143+
case IntValue(num) if num > 0 => Limit(num, query).success
144+
case IntValue(num) => Result.failure(s"Expected limit > 0, found $num")
145+
case other => Result.failure(s"Expected limit > 0, found $other")
146+
}
147+
148+
def mkOrderBy(query: Query, order: Value): Result[Query] =
149+
order match {
150+
case AbsentValue | NullValue => query.success
151+
case OrderValue(o) =>
152+
OrderBy(
153+
OrderSelections(
154+
List(OrderSelection[String](EntityType / "name", ascending = o.ascending))),
155+
query
156+
).success
157+
case _ => Result.failure(s"Expected order value, found $order")
158+
}
159+
160+
override val selectElaborator = SelectElaborator {
161+
case (QueryType, "entities", List(Binding("order", order), Binding("limit", limit))) =>
162+
Elab.transformChild(child =>
163+
for {
164+
oc <- mkOrderBy(child, order)
165+
lc <- mkLimit(oc, limit)
166+
} yield lc)
167+
}
168+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
2+
// Copyright (c) 2016-2025 Grackle Contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package grackle.sql.test
17+
18+
import cats.effect.IO
19+
import io.circe.literal._
20+
import munit.CatsEffectSuite
21+
22+
import grackle._
23+
import grackle.test.GraphQLResponseTests.assertWeaklyEqualIO
24+
25+
// Fixture rows (see testdata/pg/union-order.sql and testdata/mssql/qualified-union-order.sql) are
26+
// seeded out of alphabetical order on purpose: id 1 "Charlie", id 2 "Alpha", id 3 "Bravo", id 4
27+
// "Delta". If ordering silently doesn't apply, the top 2 by insertion order would be
28+
// Charlie/Alpha, not the correct Alpha/Bravo - the assertion can't pass by coincidence.
29+
trait SqlUnionOrderSuite extends CatsEffectSuite {
30+
def mapping: Mapping[IO]
31+
32+
test("union branch ordering with limit") {
33+
val query = """
34+
query {
35+
entities(order: ASC, limit: 2) {
36+
... on ItemA { name }
37+
... on ItemB { name }
38+
}
39+
}
40+
"""
41+
42+
val expected = json"""
43+
{
44+
"data" : {
45+
"entities" : [
46+
{ "name" : "Alpha" },
47+
{ "name" : "Bravo" }
48+
]
49+
}
50+
}
51+
"""
52+
53+
assertWeaklyEqualIO(mapping.compileAndRun(query), expected)
54+
}
55+
}

testdata/mssql/union-order.sql

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
CREATE SCHEMA qualified;
2+
GO
3+
4+
CREATE TABLE qualified.union_order_entities (
5+
id VARCHAR(100) NOT NULL PRIMARY KEY,
6+
entity_type VARCHAR(100) NOT NULL,
7+
name VARCHAR(100) NOT NULL
8+
);
9+
10+
INSERT INTO qualified.union_order_entities (id, entity_type, name) VALUES
11+
('1', 'ItemA', 'Charlie'),
12+
('2', 'ItemB', 'Alpha'),
13+
('3', 'ItemA', 'Bravo'),
14+
('4', 'ItemB', 'Delta');
15+
16+
GO

testdata/oracle/union-order.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
CREATE TABLE QUALIFIED.union_order_entities (
2+
id VARCHAR2(100) NOT NULL PRIMARY KEY,
3+
entity_type VARCHAR2(100) NOT NULL,
4+
name VARCHAR2(100) NOT NULL
5+
);
6+
7+
INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('1', 'ItemA', 'Charlie');
8+
INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('2', 'ItemB', 'Alpha');
9+
INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('3', 'ItemA', 'Bravo');
10+
INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('4', 'ItemB', 'Delta');

testdata/pg/union-order.sql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- schema `qualified` is created by qualified-names.sql, which loads first
2+
CREATE TABLE qualified.union_order_entities (
3+
id text NOT NULL PRIMARY KEY,
4+
entity_type text NOT NULL,
5+
name text NOT NULL
6+
);
7+
8+
INSERT INTO qualified.union_order_entities (id, entity_type, name) VALUES
9+
('1', 'ItemA', 'Charlie'),
10+
('2', 'ItemB', 'Alpha'),
11+
('3', 'ItemA', 'Bravo'),
12+
('4', 'ItemB', 'Delta');

0 commit comments

Comments
 (0)