diff --git a/build.sbt b/build.sbt index c891a096..456ccd42 100644 --- a/build.sbt +++ b/build.sbt @@ -36,7 +36,7 @@ ThisBuild / scalaVersion := Scala2 ThisBuild / crossScalaVersions := Seq(Scala2, Scala3) ThisBuild / tlJdkRelease := Some(11) -ThisBuild / tlBaseVersion := "0.29" +ThisBuild / tlBaseVersion := "0.30" ThisBuild / startYear := Some(2019) ThisBuild / licenses := Seq(License.Apache2) ThisBuild / developers := List( diff --git a/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala b/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala index d2b11c4b..51ce66c1 100644 --- a/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala +++ b/modules/doobie-mssql/src/test/scala/DoobieMSSqlSuites.scala @@ -191,6 +191,10 @@ final class NestedEffectsSuite extends DoobieMSSqlDatabaseSuite with SqlNestedEf } } +final class NullableParentSuite extends DoobieMSSqlDatabaseSuite with SqlNullableParentSuite { + lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlNullableParentMapping[IO] +} + final class NullOrderingSuite extends DoobieMSSqlDatabaseSuite with SqlNullOrderingSuite { lazy val mapping = new DoobieMSSqlTestMapping(transactor) with SqlNullOrderingMapping[IO] } diff --git a/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala b/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala index e53bf36f..56418c46 100644 --- a/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala +++ b/modules/doobie-oracle/src/test/scala/DoobieOracleSuites.scala @@ -197,6 +197,10 @@ final class NestedEffectsSuite extends DoobieOracleDatabaseSuite with SqlNestedE } } +final class NullableParentSuite extends DoobieOracleDatabaseSuite with SqlNullableParentSuite { + lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlNullableParentMapping[IO] +} + final class NullOrderingSuite extends DoobieOracleDatabaseSuite with SqlNullOrderingSuite { lazy val mapping = new DoobieOracleTestMapping(transactor) with SqlNullOrderingMapping[IO] } diff --git a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala index 470ac7c7..83b9c444 100644 --- a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala +++ b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala @@ -193,6 +193,10 @@ final class NestedEffectsSuite extends DoobiePgDatabaseSuite with SqlNestedEffec } } +final class NullableParentSuite extends DoobiePgDatabaseSuite with SqlNullableParentSuite { + lazy val mapping = new DoobiePgTestMapping(transactor) with SqlNullableParentMapping[IO] +} + final class NullOrderingSuite extends DoobiePgDatabaseSuite with SqlNullOrderingSuite { lazy val mapping = new DoobiePgTestMapping(transactor) with SqlNullOrderingMapping[IO] } diff --git a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala index 250f3abe..1834751c 100644 --- a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala +++ b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala @@ -198,6 +198,10 @@ final class NestedEffectsSuite extends SkunkDatabaseSuite with SqlNestedEffectsS } } +final class NullableParentSuite extends SkunkDatabaseSuite with SqlNullableParentSuite { + lazy val mapping = new SkunkTestMapping(pool) with SqlNullableParentMapping[IO] +} + final class NullOrderingSuite extends SkunkDatabaseSuite with SqlNullOrderingSuite { lazy val mapping = new SkunkTestMapping(pool) with SqlNullOrderingMapping[IO] } diff --git a/modules/sql-core/src/main/scala/SqlMapping.scala b/modules/sql-core/src/main/scala/SqlMapping.scala index 272866fc..a06a39c1 100644 --- a/modules/sql-core/src/main/scala/SqlMapping.scala +++ b/modules/sql-core/src/main/scala/SqlMapping.scala @@ -1385,7 +1385,19 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self trait Laterality { def toFragment: Fragment def joinToFragment(join: SqlJoin, subquery: SubqueryRef): Aliased[Fragment] - def joinPredicates(join: SqlJoin): List[Predicate] + + /** + * Distribute the conditions of `join` between the join itself and the enclosing select. + * + * Yields a possibly rewritten join, together with the predicates which must be added to + * the enclosing select's WHERE clause for the join to have its intended semantics. + * + * Lateralities which render the join with an `ON` clause need neither, and so yield the + * join unchanged and no predicates. + */ + def distributeJoinConditions( + join: SqlJoin, + subquery: SubqueryRef): (SqlJoin, List[Predicate]) } object Laterality { def apply(lateral: Boolean, inner: Boolean): Laterality = @@ -1395,21 +1407,95 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self def toFragment: Fragment = Fragments.empty def joinToFragment(join: SqlJoin, subquery: SubqueryRef): Aliased[Fragment] = join.toFragmentWithoutLaterality - def joinPredicates(join: SqlJoin): List[Predicate] = Nil + def distributeJoinConditions( + join: SqlJoin, + subquery: SubqueryRef): (SqlJoin, List[Predicate]) = (join, Nil) } case object Lateral extends Laterality { def toFragment: Fragment = Fragments.const("LATERAL ") def joinToFragment(join: SqlJoin, subquery: SubqueryRef): Aliased[Fragment] = join.toFragmentWithoutLaterality - def joinPredicates(join: SqlJoin): List[Predicate] = Nil + def distributeJoinConditions( + join: SqlJoin, + subquery: SubqueryRef): (SqlJoin, List[Predicate]) = (join, Nil) } case class Apply(inner: Boolean) extends Laterality { def toFragment: Fragment = Fragments.const(if (inner) "CROSS " else "OUTER ") |+| Fragments.const("APPLY ") def joinToFragment(join: SqlJoin, subquery: SubqueryRef): Aliased[Fragment] = subquery.toDefFragment - def joinPredicates(join: SqlJoin): List[Predicate] = + + /** + * An `APPLY` join is rendered without an `ON` clause, so its conditions have to be + * expressed elsewhere. For `CROSS APPLY` they are lifted into the enclosing select's + * WHERE clause, which for an inner join is equivalent to an `ON` clause. + * + * For `OUTER APPLY` lifting them would discard precisely the null padded rows the outer + * join produced, the WHERE clause being applied after the join. Its subquery is instead + * wrapped in a correlating select, which is legal because `APPLY` is lateral and is + * what makes `OUTER APPLY` equivalent to `LEFT JOIN LATERAL`. A subquery that can't be + * correlated has no correct rendering, so `correlate` failing is treated as a bug. + */ + def distributeJoinConditions( + join: SqlJoin, + subquery: SubqueryRef): (SqlJoin, List[Predicate]) = + if (inner || join.isPredicate) (join, liftedPredicates(join)) + else + // Every OUTER APPLY reachable here can be correlated; a None is a bug, not a query. + correlate(join, subquery) + .map((_, Nil)) + .getOrElse(throw new SqlMappingException( + s"OUTER APPLY subquery '${subquery.name}' could not be correlated")) + + /** + * The join's conditions expressed as predicates of the enclosing select. + */ + private def liftedPredicates(join: SqlJoin): List[Predicate] = if (!join.isPredicate) join.on.map { case (p, c) => Eql(p.toTerm, c.toTerm) } else Nil + + /** + * Yields a copy of `join` with its conditions moved inside its subquery. + * + * The subquery is wrapped in a select which applies the conditions to its result, so + * that any LIMIT, OFFSET or DISTINCT within is evaluated first, as it would be were + * they in the ON clause of a `LEFT JOIN LATERAL`, and so that only the subquery's + * exposed columns are in scope where they are applied — a table of the enclosing select + * can otherwise be shadowed by a same named table within. + * + * Yields `None` only for a subquery shape it doesn't handle; no `OUTER APPLY` produces + * one, so the caller treats a `None` as a bug rather than lifting. A subquery already + * such a wrapper is yielded unchanged, so re-nesting doesn't correlate it twice. + */ + private def correlate(join: SqlJoin, subquery: SubqueryRef): Option[SqlJoin] = + subquery.subquery match { + case sel: SqlSelect if sel.withs.isEmpty => + sel.table match { + case wrapped: SubqueryRef if wrapped.correlated && sel.wheres.nonEmpty => + Some(join) + case table: TableRef => + val exposed = + join.on.traverse { + case (p, c) => sel.cols.find(_ == c.subst(subquery, table)).map((p, _)) + } + exposed.map { exposed0 => + val wrapper = + sel.toSubquery(correlationName(subquery), NotLateral, correlated = true) + val wheres = + exposed0.map { + case (p, c) => Eql(p.toTerm, c.derive(wrapper.table).toTerm) + } + join.copy(child = subquery.copy(subquery = wrapper.copy(wheres = wheres))) + } + case _ => None + } + case _ => None + } + + /** + * The alias given to the correlating select `correlate` wraps around `subquery`. Only + * cosmetic: the wrapper is recognised by its `correlated` flag, not by this name. + */ + private def correlationName(subquery: SubqueryRef): String = subquery.name + "_corr" } } @@ -1420,7 +1506,8 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self context: Context, name: String, subquery: SqlQuery, - laterality: Laterality) + laterality: Laterality, + correlated: Boolean = false) extends TableExpr { def owns(col: SqlColumn): Boolean = col.owner.isSameOwner(this) || subquery.owns(col) def contains(other: ColumnOwner): Boolean = isSameOwner(other) || subquery.contains(other) @@ -2377,6 +2464,20 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self parentTableForType(parentContext).flatMap { parentTable => val inner = !context.tpe.isNullable && !context.tpe.isList + // A nested select may be flattened into this select's join chain only if doing so + // can't cost this select rows. + // + // Flattening moves the nested select's joins into this chain, below the join which + // attaches it. Where that join is LEFT and finds no match it still yields a row + // padded with NULLs, and an INNER join below discards it — losing a row which + // should have been returned with the nested field simply null or empty. Every join + // in `nested.joins` is downstream of that select's own table, so all of them land + // below the attaching join and see its padded columns, directly or transitively. + // Flattening is safe, then, if this join is INNER or the nested select has no + // INNER joins. + def mergePreservesRows(nested: SqlSelect): Boolean = + inner || !nested.joins.exists(_.inner) + def mkJoins(joins: List[Join]): SqlSelect = { def mkSubquery( multiTable: Boolean, @@ -2384,11 +2485,14 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self joinCols: List[SqlColumn], suffix: String): SqlSelect = { def isMergeable: Boolean = - !multiTable && !nested.joins.exists(_.isPredicate) && nested - .wheres - .isEmpty && nested.orders.isEmpty && nested.offset.isEmpty && nested - .limit - .isEmpty && !nested.isDistinct + mergePreservesRows(nested) && + !multiTable && + !nested.joins.exists(_.isPredicate) && + nested.wheres.isEmpty && + nested.orders.isEmpty && + nested.offset.isEmpty && + nested.limit.isEmpty && + !nested.isDistinct if (isMergeable) nested else { @@ -2410,22 +2514,23 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self cols: List[SqlColumn], wheres: List[Predicate], joins: List[SqlJoin]): SqlSelect = { - val extraWheres = - if (!outer) Nil - else - joins.flatMap { join => - join.child match { - case sq: SubqueryRef => sq.laterality.joinPredicates(join) - case _ => Nil - } + val distributed = + joins.map { join => + join.child match { + case sq: SubqueryRef if outer => + sq.laterality.distributeJoinConditions(join, sq) + case _ => (join, Nil) } + } + val joins0 = distributed.map(_._1) + val extraWheres = distributed.flatMap(_._2) SqlSelect( context = parentContext, withs = withs, table = table, cols = cols, - joins = joins, + joins = joins0, wheres = wheres ++ extraWheres, orders = Nil, offset = None, @@ -2458,6 +2563,10 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self true) val assocJoin = lastJoin.toSqlJoin(lastJoinParentTable, assocTable, inner) + // This join is on the key between a table and itself, so it always matches and + // an outer join is as good as an inner one. `assocJoin` carries the join's real + // innerness, and it's that which decides how a subquery beneath it distributes + // its conditions. val finalJoin = SqlJoin( assocTable, @@ -3143,8 +3252,11 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self def toSubquery(name: String): Result[SqlSelect] = toSubquery(name, Laterality.NotLateral).success - def toSubquery(name: String, lateral: Laterality): SqlSelect = { - val ref = SubqueryRef(context, name, this, lateral) + def toSubquery( + name: String, + lateral: Laterality, + correlated: Boolean = false): SqlSelect = { + val ref = SubqueryRef(context, name, this, lateral, correlated) SqlSelect( context, Nil, @@ -3165,7 +3277,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self */ def subqueryToWithQuery: SqlSelect = { table match { - case SubqueryRef(_, name, sq, _) => + case SubqueryRef(_, name, sq, _, _) => val with0 = WithRef(context, name + "_base", sq) val ref = TableExpr.DerivedTableRef(context, Some(name), with0, true) copy(withs = with0 :: withs, table = ref) @@ -3465,7 +3577,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self */ def isPredicate: Boolean = child match { - case SubqueryRef(_, _, sq: SqlSelect, _) => sq.predicate + case SubqueryRef(_, _, sq: SqlSelect, _, _) => sq.predicate case _ => false } diff --git a/modules/sql-core/src/test/scala/SqlNullableParentMapping.scala b/modules/sql-core/src/test/scala/SqlNullableParentMapping.scala new file mode 100644 index 00000000..983f8a41 --- /dev/null +++ b/modules/sql-core/src/test/scala/SqlNullableParentMapping.scala @@ -0,0 +1,137 @@ +// 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.syntax._ + +trait SqlNullableParentMapping[F[_]] extends SqlTestMapping[F] { + + object aTable extends TableDef("nullable_parent_a") { + val id = col("id", int4) + val bId = col("b_id", nullable(int4)) + val name = col("name", text) + } + + object bTable extends TableDef("nullable_parent_b") { + val id = col("id", int4) + val cId = col("c_id", int4) + val name = col("name", text) + } + + object cTable extends TableDef("nullable_parent_c") { + val id = col("id", int4) + val name = col("name", text) + } + + object dTable extends TableDef("nullable_parent_d") { + val id = col("id", int4) + val name = col("name", text) + } + + object eTable extends TableDef("nullable_parent_e") { + val id = col("id", int4) + val dId = col("d_id", int4) + val fId = col("f_id", int4) + val otherDId = col("other_d_id", int4) + val name = col("name", text) + } + + object fTable extends TableDef("nullable_parent_f") { + val id = col("id", int4) + val name = col("name", text) + } + + val schema = + schema""" + type Query { + as: [A!]! + ds: [D!]! + } + type A { + name: String! + b: B + } + type B { + name: String! + c: C! + } + type C { + name: String! + } + type D { + name: String! + es: [E!]! + } + type E { + name: String! + f: F! + otherD: D! + } + type F { + name: String! + } + """ + + val QueryType = schema.ref("Query") + val AType = schema.ref("A") + val BType = schema.ref("B") + val CType = schema.ref("C") + val DType = schema.ref("D") + val EType = schema.ref("E") + val FType = schema.ref("F") + + val typeMappings = + TypeMappings( + ObjectMapping(QueryType)( + SqlObject("as"), + SqlObject("ds") + ), + ObjectMapping(AType)( + SqlField("id", aTable.id, key = true, hidden = true), + SqlField("bId", aTable.bId, hidden = true), + SqlField("name", aTable.name), + SqlObject("b", Join(aTable.bId, bTable.id)) + ), + ObjectMapping(BType)( + SqlField("id", bTable.id, key = true, hidden = true), + SqlField("cId", bTable.cId, hidden = true), + SqlField("name", bTable.name), + SqlObject("c", Join(bTable.cId, cTable.id)) + ), + ObjectMapping(CType)( + SqlField("id", cTable.id, key = true, hidden = true), + SqlField("name", cTable.name) + ), + ObjectMapping(DType)( + SqlField("id", dTable.id, key = true, hidden = true), + SqlField("name", dTable.name), + SqlObject("es", Join(dTable.id, eTable.dId)) + ), + ObjectMapping(EType)( + SqlField("id", eTable.id, key = true, hidden = true), + SqlField("dId", eTable.dId, hidden = true), + SqlField("fId", eTable.fId, hidden = true), + SqlField("otherDId", eTable.otherDId, hidden = true), + SqlField("name", eTable.name), + SqlObject("f", Join(eTable.fId, fTable.id)), + SqlObject("otherD", Join(eTable.otherDId, dTable.id)) + ), + ObjectMapping(FType)( + SqlField("id", fTable.id, key = true, hidden = true), + SqlField("name", fTable.name) + ) + ) +} diff --git a/modules/sql-core/src/test/scala/SqlNullableParentSuite.scala b/modules/sql-core/src/test/scala/SqlNullableParentSuite.scala new file mode 100644 index 00000000..29348575 --- /dev/null +++ b/modules/sql-core/src/test/scala/SqlNullableParentSuite.scala @@ -0,0 +1,245 @@ +// 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 + +/** + * A non-null field beneath a nullable one, or beneath a list, must not remove rows whose parent + * is absent or empty. + * + * The non-null-ness of such a field only constrains anything where its parent exists at all: + * per the GraphQL spec, completing a nullable field with a null result returns null without + * executing its sub-selections, and a `D` with no `E`s must still be returned with `es` as + * `[]`. + */ +trait SqlNullableParentSuite extends CatsEffectSuite { + def mapping: Mapping[IO] + + test("a nullable field that is absent does not remove its row") { + val query = """ + query { + as { + name + b { + name + c { + name + } + } + } + } + """ + + // `a-with-dangling-b` names a `B` which doesn't exist, so its row is reported with a null + // `b` rather than as an error. That isn't what the spec asks for — a non-null field with no + // row should raise an execution error propagated to the nearest nullable ancestor — but it + // is a separate defect from the one under test here, and returning the row is already an + // improvement on dropping it. Note that expecting `data` alone also expects no `errors` + // entry, so this has to be revisited when that defect is addressed. + val expected = json""" + { + "data" : { + "as" : [ + { + "name" : "a-with-good-b", + "b" : { + "name" : "b-with-c", + "c" : { + "name" : "cat-1" + } + } + }, + { + "name" : "a-with-dangling-b", + "b" : null + }, + { + "name" : "a-without-b", + "b" : null + } + ] + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } + + test("the same query stopping above the non-null field is unaffected") { + val query = """ + query { + as { + name + b { + name + } + } + } + """ + + val expected = json""" + { + "data" : { + "as" : [ + { + "name" : "a-with-good-b", + "b" : { + "name" : "b-with-c" + } + }, + { + "name" : "a-with-dangling-b", + "b" : { + "name" : "b-with-dangling-c" + } + }, + { + "name" : "a-without-b", + "b" : null + } + ] + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } + + test("a list field that is empty does not remove its row") { + val query = """ + query { + ds { + name + es { + name + f { + name + } + } + } + } + """ + + // `d-without-es` is the row at issue: it has no `E`s, so nothing beneath `es` is selected at + // all, and the non-null-ness of `f` cannot bear on whether the `D` itself is returned. + val expected = json""" + { + "data" : { + "ds" : [ + { + "name" : "d-with-es", + "es" : [ + { + "name" : "e-with-f", + "f" : { + "name" : "fish-1" + } + }, + { + "name" : "e-with-another-f", + "f" : { + "name" : "fish-2" + } + } + ] + }, + { + "name" : "d-without-es", + "es" : [] + } + ] + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } + + // Reaching a second list through a non-null field nests the same shape twice, which on a + // backend joining by `APPLY` presents the inner join to correlation a second time, once the + // first has already rewritten it. `e-with-f` is the row at issue: its `otherD` is the `D` + // with no `E`s, so only a query which descends that far has a row to lose. + test("an empty list reached through a back reference does not remove its row") { + val query = """ + query { + ds { + name + es { + name + otherD { + name + es { + name + f { + name + } + } + } + } + } + } + """ + + val expected = json""" + { + "data" : { + "ds" : [ + { + "name" : "d-with-es", + "es" : [ + { + "name" : "e-with-f", + "otherD" : { + "name" : "d-without-es", + "es" : [] + } + }, + { + "name" : "e-with-another-f", + "otherD" : { + "name" : "d-with-es", + "es" : [ + { + "name" : "e-with-f", + "f" : { "name" : "fish-1" } + }, + { + "name" : "e-with-another-f", + "f" : { "name" : "fish-2" } + } + ] + } + } + ] + }, + { + "name" : "d-without-es", + "es" : [] + } + ] + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } +} diff --git a/testdata/mssql/nullable-parent.sql b/testdata/mssql/nullable-parent.sql new file mode 100644 index 00000000..38de5a79 --- /dev/null +++ b/testdata/mssql/nullable-parent.sql @@ -0,0 +1,60 @@ +CREATE TABLE nullable_parent_c ( + id INTEGER PRIMARY KEY, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_b ( + id INTEGER PRIMARY KEY, + c_id INTEGER NOT NULL, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_a ( + id INTEGER PRIMARY KEY, + b_id INTEGER, + name VARCHAR(64) NOT NULL +); + +INSERT INTO nullable_parent_c (id, name) VALUES +(1, 'cat-1'); + +INSERT INTO nullable_parent_b (id, c_id, name) VALUES +(10, 1, 'b-with-c'), +(20, 999, 'b-with-dangling-c'); + +INSERT INTO nullable_parent_a (id, b_id, name) VALUES +(100, 10, 'a-with-good-b'), +(200, 20, 'a-with-dangling-b'), +(300, NULL, 'a-without-b'); + +CREATE TABLE nullable_parent_f ( + id INTEGER PRIMARY KEY, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_e ( + id INTEGER PRIMARY KEY, + d_id INTEGER NOT NULL, + f_id INTEGER NOT NULL, + other_d_id INTEGER NOT NULL, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_d ( + id INTEGER PRIMARY KEY, + name VARCHAR(64) NOT NULL +); + +INSERT INTO nullable_parent_f (id, name) VALUES +(1, 'fish-1'), +(2, 'fish-2'); + +INSERT INTO nullable_parent_e (id, d_id, f_id, other_d_id, name) VALUES +(10, 100, 1, 200, 'e-with-f'), +(11, 100, 2, 100, 'e-with-another-f'); + +INSERT INTO nullable_parent_d (id, name) VALUES +(100, 'd-with-es'), +(200, 'd-without-es'); + +GO diff --git a/testdata/oracle/nullable-parent.sql b/testdata/oracle/nullable-parent.sql new file mode 100644 index 00000000..fbc89990 --- /dev/null +++ b/testdata/oracle/nullable-parent.sql @@ -0,0 +1,58 @@ +CREATE TABLE nullable_parent_c ( + id INTEGER PRIMARY KEY, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_b ( + id INTEGER PRIMARY KEY, + c_id INTEGER NOT NULL, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_a ( + id INTEGER PRIMARY KEY, + b_id INTEGER, + name VARCHAR(64) NOT NULL +); + +INSERT INTO nullable_parent_c (id, name) VALUES +(1, 'cat-1'); + +INSERT INTO nullable_parent_b (id, c_id, name) VALUES +(10, 1, 'b-with-c'), +(20, 999, 'b-with-dangling-c'); + +INSERT INTO nullable_parent_a (id, b_id, name) VALUES +(100, 10, 'a-with-good-b'), +(200, 20, 'a-with-dangling-b'), +(300, NULL, 'a-without-b'); + +CREATE TABLE nullable_parent_f ( + id INTEGER PRIMARY KEY, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_e ( + id INTEGER PRIMARY KEY, + d_id INTEGER NOT NULL, + f_id INTEGER NOT NULL, + other_d_id INTEGER NOT NULL, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE nullable_parent_d ( + id INTEGER PRIMARY KEY, + name VARCHAR(64) NOT NULL +); + +INSERT INTO nullable_parent_f (id, name) VALUES +(1, 'fish-1'), +(2, 'fish-2'); + +INSERT INTO nullable_parent_e (id, d_id, f_id, other_d_id, name) VALUES +(10, 100, 1, 200, 'e-with-f'), +(11, 100, 2, 100, 'e-with-another-f'); + +INSERT INTO nullable_parent_d (id, name) VALUES +(100, 'd-with-es'), +(200, 'd-without-es'); diff --git a/testdata/pg/nullable-parent.sql b/testdata/pg/nullable-parent.sql new file mode 100644 index 00000000..5222355a --- /dev/null +++ b/testdata/pg/nullable-parent.sql @@ -0,0 +1,64 @@ +CREATE TABLE nullable_parent_c ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE nullable_parent_b ( + id INTEGER PRIMARY KEY, + c_id INTEGER NOT NULL, + name TEXT NOT NULL +); + +CREATE TABLE nullable_parent_a ( + id INTEGER PRIMARY KEY, + b_id INTEGER, + name TEXT NOT NULL +); + +COPY nullable_parent_c (id, name) FROM STDIN WITH DELIMITER '|'; +1|cat-1 +\. + +COPY nullable_parent_b (id, c_id, name) FROM STDIN WITH DELIMITER '|'; +10|1|b-with-c +20|999|b-with-dangling-c +\. + +COPY nullable_parent_a (id, b_id, name) FROM STDIN WITH DELIMITER '|'; +100|10|a-with-good-b +200|20|a-with-dangling-b +300|\N|a-without-b +\. + +CREATE TABLE nullable_parent_f ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE nullable_parent_e ( + id INTEGER PRIMARY KEY, + d_id INTEGER NOT NULL, + f_id INTEGER NOT NULL, + other_d_id INTEGER NOT NULL, + name TEXT NOT NULL +); + +CREATE TABLE nullable_parent_d ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); + +COPY nullable_parent_f (id, name) FROM STDIN WITH DELIMITER '|'; +1|fish-1 +2|fish-2 +\. + +COPY nullable_parent_e (id, d_id, f_id, other_d_id, name) FROM STDIN WITH DELIMITER '|'; +10|100|1|200|e-with-f +11|100|2|100|e-with-another-f +\. + +COPY nullable_parent_d (id, name) FROM STDIN WITH DELIMITER '|'; +100|d-with-es +200|d-without-es +\.