diff --git a/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala b/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala index 51ce66c1..3ac78006 100644 --- a/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala +++ b/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala @@ -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] } diff --git a/modules/doobie-oracle/src/test/resources/scripts/01_create_user.sql b/modules/doobie-oracle/src/test/resources/scripts/01_create_user.sql index 15da74de..fb5f06dc 100644 --- a/modules/doobie-oracle/src/test/resources/scripts/01_create_user.sql +++ b/modules/doobie-oracle/src/test/resources/scripts/01_create_user.sql @@ -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; diff --git a/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala b/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala index 56418c46..0e2f4b46 100644 --- a/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala +++ b/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala @@ -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] } diff --git a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala index 89de1bb2..65f7b43f 100644 --- a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala +++ b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala @@ -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 { diff --git a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala index 242c22bb..158fc028 100644 --- a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala +++ b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala @@ -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 { diff --git a/modules/sql-core/src/test/scala/SqlUnionOrderMapping.scala b/modules/sql-core/src/test/scala/SqlUnionOrderMapping.scala new file mode 100644 index 00000000..3a4aea48 --- /dev/null +++ b/modules/sql-core/src/test/scala/SqlUnionOrderMapping.scala @@ -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) + } +} diff --git a/modules/sql-core/src/test/scala/SqlUnionOrderSuite.scala b/modules/sql-core/src/test/scala/SqlUnionOrderSuite.scala new file mode 100644 index 00000000..a601a5ba --- /dev/null +++ b/modules/sql-core/src/test/scala/SqlUnionOrderSuite.scala @@ -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) + } +} diff --git a/testdata/mssql/union-order.sql b/testdata/mssql/union-order.sql new file mode 100644 index 00000000..6960c145 --- /dev/null +++ b/testdata/mssql/union-order.sql @@ -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 diff --git a/testdata/oracle/union-order.sql b/testdata/oracle/union-order.sql new file mode 100644 index 00000000..5670feaa --- /dev/null +++ b/testdata/oracle/union-order.sql @@ -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'); diff --git a/testdata/pg/union-order.sql b/testdata/pg/union-order.sql new file mode 100644 index 00000000..27f6f974 --- /dev/null +++ b/testdata/pg/union-order.sql @@ -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');