Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ final class TreeSuite extends DoobieMSSqlDatabaseSuite with SqlTreeSuite {
lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlTreeMapping[IO]
}

final class UnionOrderSuite extends DoobieMSSqlDatabaseSuite with SqlUnionOrderSuite {
lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlUnionOrderMapping[IO]
}

final class UnionsSuite extends DoobieMSSqlDatabaseSuite with SqlUnionSuite {
lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlUnionsMapping[IO]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@ ALTER SESSION SET CONTAINER=FREEPDB1;
CREATE USER TEST IDENTIFIED BY test QUOTA UNLIMITED ON USERS;

GRANT CONNECT, RESOURCE TO TEST;

-- A schema is a user in Oracle, so a schema-qualified fixture needs a second user. The fixtures
-- all run as TEST, so TEST needs ANY rights to populate it. CREATE ANY INDEX is required as well
-- as CREATE ANY TABLE, because a PRIMARY KEY creates an index in the other schema.
CREATE USER QUALIFIED IDENTIFIED BY test QUOTA UNLIMITED ON USERS;

GRANT CREATE ANY TABLE, CREATE ANY INDEX, INSERT ANY TABLE, SELECT ANY TABLE TO TEST;
4 changes: 4 additions & 0 deletions modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ final class NullOrderingSuite extends DoobieOracleDatabaseSuite with SqlNullOrde
lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlNullOrderingMapping[IO]
}

final class UnionOrderSuite extends DoobieOracleDatabaseSuite with SqlUnionOrderSuite {
lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlUnionOrderMapping[IO]
}

final class Paging1Suite extends DoobieOracleDatabaseSuite with SqlPaging1Suite {
lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlPaging1Mapping[IO]
}
Expand Down
4 changes: 4 additions & 0 deletions modules/doobie-pg/src/test/scala/DoobiePgSuites.scala
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ final class TableNameSuite extends DoobiePgDatabaseSuite with SqlTableNameSuite
lazy val mapping = new DoobiePgTestMapping(transactor) with SqlQualifiedNamesMapping[IO]
}

final class UnionOrderSuite extends DoobiePgDatabaseSuite with SqlUnionOrderSuite {
lazy val mapping = new DoobiePgTestMapping(transactor) with SqlUnionOrderMapping[IO]
}

final class RecursiveInterfacesSuite
extends DoobiePgDatabaseSuite
with SqlRecursiveInterfacesSuite {
Expand Down
4 changes: 4 additions & 0 deletions modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ final class TableNameSuite extends SkunkDatabaseSuite with SqlTableNameSuite {
lazy val mapping = new SkunkTestMapping(pool) with SqlQualifiedNamesMapping[IO]
}

final class UnionOrderSuite extends SkunkDatabaseSuite with SqlUnionOrderSuite {
lazy val mapping = new SkunkTestMapping(pool) with SqlUnionOrderMapping[IO]
}

final class RecursiveInterfacesSuite
extends SkunkDatabaseSuite
with SqlRecursiveInterfacesSuite {
Expand Down
168 changes: 168 additions & 0 deletions modules/sql-core/src/test/scala/SqlUnionOrderMapping.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// Copyright (c) 2016-2025 Grackle Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package grackle.sql.test

import grackle._
import grackle.Predicate._
import grackle.Query.{Binding, Limit, OrderBy, OrderSelection, OrderSelections}
import grackle.QueryCompiler.{Elab, SelectElaborator}
import grackle.Value.{AbsentValue, EnumValue, IntValue, NullValue}
import grackle.syntax._

// Covers a gap that predates issue #342 entirely: no fixture anywhere combines a union-typed
// field with both `order` and `limit`, which is the only condition under which
// SqlUnion.addFilterOrderByOffsetLimit pushes ordering into individual union branches
// (SqlMapping.scala: `branchOrderBy = limit.flatMap(_ => orderBy)`). On backends where
// encapsulateUnionBranch does real work (MSSQL), this is also the only path that exercises it.
// The table is schema-qualified on every backend, so union-branch encapsulation is exercised
// against a qualified name rather than only on MSSQL. The name is "union_order_entities", not
// "entities" - that name is already taken by testdata/{pg,mssql}/interfaces.sql on both backends.
trait SqlUnionOrderMapping[F[_]] extends SqlTestMapping[F] {

object entities extends TableDef("qualified.union_order_entities") {
val id = col("id", text)
val entityType = col("entity_type", text)
val name = col("name", text)
}

val schema =
schema"""
type Query {
entities(order: Order, limit: Int): [Entity!]!
}
type ItemA {
id: String!
name: String!
}
type ItemB {
id: String!
name: String!
}
union Entity = ItemA | ItemB
enum Order {
ASC
DESC
}
"""

val QueryType = schema.ref("Query")
val ItemAType = schema.ref("ItemA")
val ItemBType = schema.ref("ItemB")
val EntityType = schema.ref("Entity")

val typeMappings =
List(
ObjectMapping(
tpe = QueryType,
fieldMappings = List(
SqlObject("entities")
)
),
SqlUnionMapping(
tpe = EntityType,
discriminator = entityTypeDiscriminator,
fieldMappings = List(
SqlField("id", entities.id, key = true, hidden = true),
SqlField("name", entities.name, hidden = true),
SqlField("entityType", entities.entityType, discriminator = true, hidden = true)
)
),
ObjectMapping(
tpe = ItemAType,
fieldMappings = List(
SqlField("id", entities.id, key = true),
SqlField("name", entities.name)
)
),
ObjectMapping(
tpe = ItemBType,
fieldMappings = List(
SqlField("id", entities.id, key = true),
SqlField("name", entities.name)
)
)
)

object entityTypeDiscriminator extends SqlDiscriminator {
def discriminate(c: Cursor): Result[Type] =
for {
et <- c.fieldAs[String]("entityType")
} yield et match {
case "ItemA" => ItemAType
case "ItemB" => ItemBType
}

def narrowPredicate(subtpe: Type): Result[Predicate] = {
def mkPredicate(tpe: String): Result[Predicate] =
Eql(EntityType / "entityType", Const(tpe)).success

subtpe match {
case ItemAType => mkPredicate("ItemA")
case ItemBType => mkPredicate("ItemB")
case _ => Result.internalError(s"Invalid discriminator: $subtpe")
}
}
}

sealed trait ListOrder {
def ascending: Boolean
}
object ListOrder {
case object Ascending extends ListOrder { def ascending = true }
case object Descending extends ListOrder { def ascending = false }

def fromGraphQLString(s: String): Option[ListOrder] =
s.trim.toUpperCase match {
case "ASC" => Some(Ascending)
case "DESC" => Some(Descending)
case _ => None
}
}

object OrderValue {
def unapply(ev: EnumValue): Option[ListOrder] =
ListOrder.fromGraphQLString(ev.name)
}

def mkLimit(query: Query, limit: Value): Result[Query] =
limit match {
case AbsentValue | NullValue => query.success
case IntValue(num) if num > 0 => Limit(num, query).success
case IntValue(num) => Result.failure(s"Expected limit > 0, found $num")
case other => Result.failure(s"Expected limit > 0, found $other")
}

def mkOrderBy(query: Query, order: Value): Result[Query] =
order match {
case AbsentValue | NullValue => query.success
case OrderValue(o) =>
OrderBy(
OrderSelections(
List(OrderSelection[String](EntityType / "name", ascending = o.ascending))),
query
).success
case _ => Result.failure(s"Expected order value, found $order")
}

override val selectElaborator = SelectElaborator {
case (QueryType, "entities", List(Binding("order", order), Binding("limit", limit))) =>
Elab.transformChild(child =>
for {
oc <- mkOrderBy(child, order)
lc <- mkLimit(oc, limit)
} yield lc)
}
}
55 changes: 55 additions & 0 deletions modules/sql-core/src/test/scala/SqlUnionOrderSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// Copyright (c) 2016-2025 Grackle Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package grackle.sql.test

import cats.effect.IO
import io.circe.literal._
import munit.CatsEffectSuite

import grackle._
import grackle.test.GraphQLResponseTests.assertWeaklyEqualIO

// Fixture rows (see testdata/pg/union-order.sql and testdata/mssql/qualified-union-order.sql) are
// seeded out of alphabetical order on purpose: id 1 "Charlie", id 2 "Alpha", id 3 "Bravo", id 4
// "Delta". If ordering silently doesn't apply, the top 2 by insertion order would be
// Charlie/Alpha, not the correct Alpha/Bravo - the assertion can't pass by coincidence.
trait SqlUnionOrderSuite extends CatsEffectSuite {
def mapping: Mapping[IO]

test("union branch ordering with limit") {
val query = """
query {
entities(order: ASC, limit: 2) {
... on ItemA { name }
... on ItemB { name }
}
}
"""

val expected = json"""
{
"data" : {
"entities" : [
{ "name" : "Alpha" },
{ "name" : "Bravo" }
]
}
}
"""

assertWeaklyEqualIO(mapping.compileAndRun(query), expected)
}
}
16 changes: 16 additions & 0 deletions testdata/mssql/union-order.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE SCHEMA qualified;
GO

CREATE TABLE qualified.union_order_entities (
id VARCHAR(100) NOT NULL PRIMARY KEY,
entity_type VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL
);

INSERT INTO qualified.union_order_entities (id, entity_type, name) VALUES
('1', 'ItemA', 'Charlie'),
('2', 'ItemB', 'Alpha'),
('3', 'ItemA', 'Bravo'),
('4', 'ItemB', 'Delta');

GO
10 changes: 10 additions & 0 deletions testdata/oracle/union-order.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE QUALIFIED.union_order_entities (
id VARCHAR2(100) NOT NULL PRIMARY KEY,
entity_type VARCHAR2(100) NOT NULL,
name VARCHAR2(100) NOT NULL
);

INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('1', 'ItemA', 'Charlie');
INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('2', 'ItemB', 'Alpha');
INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('3', 'ItemA', 'Bravo');
INSERT INTO QUALIFIED.union_order_entities (id, entity_type, name) VALUES ('4', 'ItemB', 'Delta');
12 changes: 12 additions & 0 deletions testdata/pg/union-order.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- schema `qualified` is created by qualified-names.sql, which loads first
CREATE TABLE qualified.union_order_entities (
id text NOT NULL PRIMARY KEY,
entity_type text NOT NULL,
name text NOT NULL
);

INSERT INTO qualified.union_order_entities (id, entity_type, name) VALUES
('1', 'ItemA', 'Charlie'),
('2', 'ItemB', 'Alpha'),
('3', 'ItemA', 'Bravo'),
('4', 'ItemB', 'Delta');
Loading