diff --git a/core/src/main/scala-3/cats/data/Nullable.scala b/core/src/main/scala-3/cats/data/Nullable.scala new file mode 100644 index 0000000000..274cc5b8aa --- /dev/null +++ b/core/src/main/scala-3/cats/data/Nullable.scala @@ -0,0 +1,376 @@ +/* + * Copyright (c) 2015 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package cats.data + +import cats.{Eq, Eval, Foldable, Hash, Monoid, Order, PartialOrder, Semigroup, Show} +import scala.language.strictEquality +import scala.quoted.* + +opaque type Nullable[+A] = A | Null + +object Nullable extends NullableInstances { + type Flattened[A] = A match { + case Nullable[x] => Nullable[x] + case _ => Nullable[A] + } + + private[data] sealed trait Flattening[A, B] { + def apply(value: Nullable[A]): B + } + + private[data] object Flattening extends Flattening0 { + given [A, B](using next: Flattening[A, B]): Flattening[Nullable[A], B] with { + def apply(value: Nullable[Nullable[A]]): B = { + next(value.asInstanceOf[Nullable[A]]) + } + } + } + + private[data] trait Flattening0 { + given [A]: Flattening[A, Nullable[A]] with { + def apply(value: Nullable[A]): Nullable[A] = { + value + } + } + } + + // If we enable explicit nulls, strict equality needs this `CanEqual` for `== null` checks. + // We keep it `private[data]` (not `private`) because explicit nulls are not enabled right now, + // and a `private given` would otherwise trigger an unused-private-member warning. + private[data] given [A]: CanEqual[A | Null, Null] = CanEqual.derived + + extension [A](inline nullable: Nullable[A]) { + inline def fold[B](inline ifNull: => B)(inline f: A => B): B = { + ${ foldImpl('nullable, 'ifNull, 'f) } + } + + inline def isNull: Boolean = { + val value: A | Null = nullable + value == null + } + + inline def nonNull: Boolean = { + !isNull + } + + /** + * Transforms the non-null value. + * + * This is not a lawful `Functor` map. Because `Nullable[Nullable[A]]` collapses to + * `Nullable[A]`, composition can break: + * + * {{{ + * val n: Nullable[Nullable[Int]] = Nullable(Nullable(1)) + * val f: Int => String = _ => null + * val g: String => Int = _ => 42 + * + * val lhs = n.transform(_.transform(f)).transform(_.transform(g)) + * val rhs = n.transform(_.transform(f.andThen(g))) + * + * // lhs is null, rhs is 42 + * }}} + */ + inline def transform[B](inline f: A => B): Nullable[B] = { + fold(null: Null)(f) + } + + inline def orNull: A | Null = { + nullable + } + + inline def toOption: Option[A] = { + fold(None)(Some(_)) + } + + inline def iterator: Iterator[A] = { + fold(Iterator.empty)(Iterator.single(_)) + } + + inline def flatten[B](using flattening: Flattening[A, B]): B = { + flattening(nullable) + } + } + + inline def apply[A](inline a: A | Null): Nullable[A] = { + a + } + + def fromOption[A](opt: Option[A]): Nullable[A] = { + opt match { + case Some(a) => a + case None => null + } + } + + inline def empty[A]: Nullable[A] = { + null + } + + // Non-inline typeclass methods in this same file cannot call `n.toOption` / `n.fold` + // because `fold` is a macro defined in the same source file. + private[data] inline def nonMacroToOption[A](n: Nullable[A]): Option[A] = { + val value: A | Null = n + if value == null then { + None + } else { + Some(value.asInstanceOf[A]) + } + } + + private[data] inline def nonMacroToNullableOption[A](n: Nullable[A]): Option[Nullable[A]] = { + val value: A | Null = n + if value == null then { + None + } else { + Some(n) + } + } + + private[data] def eqvNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Eq[A]): Boolean = { + val a: A | Null = na + val b: A | Null = nb + if a == null then { + b == null + } else if b == null then { + false + } else { + A.eqv(a.asInstanceOf[A], b.asInstanceOf[A]) + } + } + + private[data] def hashNullable[A](nullable: Nullable[A])(using A: Hash[A]): Int = { + val value: A | Null = nullable + if value == null then { + None.hashCode() + } else { + A.hash(value.asInstanceOf[A]) + } + } + + private[data] def partialCompareNullable[A](na: Nullable[A], nb: Nullable[A])(using A: PartialOrder[A]): Double = { + val a: A | Null = na + val b: A | Null = nb + if a == null then { + if b == null then { + 0.0 + } else { + -1.0 + } + } else if b == null then { + 1.0 + } else { + A.partialCompare(a.asInstanceOf[A], b.asInstanceOf[A]) + } + } + + private[data] def compareNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Order[A]): Int = { + val a: A | Null = na + val b: A | Null = nb + if a == null then { + if b == null then { + 0 + } else { + -1 + } + } else if b == null then { + 1 + } else { + A.compare(a.asInstanceOf[A], b.asInstanceOf[A]) + } + } + + private def combineNullable[A](nx: Nullable[A], ny: Nullable[A])(using A: Semigroup[A]): Nullable[A] = { + val x: A | Null = nx + val y: A | Null = ny + if x == null then { + y + } else if y == null then { + x + } else { + A.combine(x.asInstanceOf[A], y.asInstanceOf[A]) + } + } + + given [A](using A: Semigroup[A]): Monoid[Nullable[A]] with { + def empty: Nullable[A] = { + Nullable.empty + } + + def combine(nx: Nullable[A], ny: Nullable[A]): Nullable[A] = { + combineNullable(nx, ny) + } + } + + given [A](using A: Show[A]): Show[Nullable[A]] with { + def show(nullable: Nullable[A]): String = { + val value: A | Null = nullable + if value == null then { + java.lang.String.valueOf(null: AnyRef) + } else { + A.show(value.asInstanceOf[A]) + } + } + } + + /* + * These methods intentionally use direct null checks instead of `fa.fold(...)`. + * `fold` is implemented as an inline macro in this same source file, and Scala 3 + * rejects calls from non-inline methods to a macro defined in the same file. + */ + given catsDataFoldableForNullable: Foldable[Nullable] with { + def foldLeft[A, B](fa: Nullable[A], b: B)(f: (B, A) => B): B = { + val value: A | Null = fa + if value == null then { + b + } else { + f(b, value.asInstanceOf[A]) + } + } + + def foldRight[A, B](fa: Nullable[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = { + val value: A | Null = fa + if value == null then { + lb + } else { + f(value.asInstanceOf[A], lb) + } + } + + override def fold[A](fa: Nullable[A])(using A: Monoid[A]): A = { + val value: A | Null = fa + if value == null then { + A.empty + } else { + value.asInstanceOf[A] + } + } + + override def combineAllOption[A](fa: Nullable[A])(using Semigroup[A]): Option[A] = { + nonMacroToOption(fa) + } + } + + /* + * Keep `fold` as a macro: + * 1. Strict equality + explicit nulls: we emit a local `CanEqual[T | Null, Null]`, + * so the generated null check remains valid under those modes. + * 2. Performance: `Expr.betaReduce` keeps `fold` codegen close to a plain `if/else` + * by reducing the `f(safe)` application at compile time when possible. + * 3. Compiler stability: the `asInstanceOf[T]` cast in the `else` branch is intentional. + * Moving/removing that cast can crash some Scala 3 versions on intersection types: + * https://github.com/scala/scala3/issues/25208 + */ + private def foldImpl[T: Type, A: Type]( + nullable: Expr[Nullable[T]], + ifNull: Expr[A], + fn: Expr[T => A] + )(using Quotes): Expr[A] = { + val nullableUnion: Expr[T | Null] = '{ $nullable.asInstanceOf[T | Null] } + + '{ + val n = $nullableUnion + given CanEqual[T | Null, Null] = CanEqual.derived + if n == null then { + $ifNull + } else { + val safe: T = n.asInstanceOf[T] + ${ Expr.betaReduce('{ $fn(safe) }) } + } + } + } +} + +sealed abstract private[data] class NullableInstances extends NullableInstances0 { + given [A](using A: Order[A]): Order[Nullable[A]] with { + def compare(na: Nullable[A], nb: Nullable[A]): Int = { + Nullable.compareNullable(na, nb) + } + + override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + if compare(na, nb) <= 0 then { + Nullable.nonMacroToNullableOption(na) + } else { + Nullable.nonMacroToNullableOption(nb) + } + } + + override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + if compare(na, nb) >= 0 then { + Nullable.nonMacroToNullableOption(na) + } else { + Nullable.nonMacroToNullableOption(nb) + } + } + } +} + +private[data] trait NullableInstances0 extends NullableInstances1 { + given [A](using A: PartialOrder[A]): PartialOrder[Nullable[A]] with { + def partialCompare(na: Nullable[A], nb: Nullable[A]): Double = { + Nullable.partialCompareNullable(na, nb) + } + + override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + val c = partialCompare(na, nb) + // Avoid boxing by calling the primitive static function. + if java.lang.Double.isNaN(c) then { + None + } else if c <= 0.0 then { + Nullable.nonMacroToNullableOption(na) + } else { + Nullable.nonMacroToNullableOption(nb) + } + } + + override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + val c = partialCompare(na, nb) + // Avoid boxing by calling the primitive static function. + if java.lang.Double.isNaN(c) then { + None + } else if c >= 0.0 then { + Nullable.nonMacroToNullableOption(na) + } else { + Nullable.nonMacroToNullableOption(nb) + } + } + } +} + +private[data] trait NullableInstances1 extends NullableInstances2 { + given [A](using A: Hash[A]): Hash[Nullable[A]] with { + def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = { + Nullable.eqvNullable(na, nb) + } + + def hash(nullable: Nullable[A]): Int = { + Nullable.hashNullable(nullable) + } + } +} + +private[data] trait NullableInstances2 { + given [A](using A: Eq[A]): Eq[Nullable[A]] with { + def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = { + Nullable.eqvNullable(na, nb) + } + } +} diff --git a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala new file mode 100644 index 0000000000..5417cfa9d7 --- /dev/null +++ b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2015 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package cats.tests + +import cats.{Applicative, Eq, Foldable, Functor, Hash, Monad, Monoid, Order, PartialOrder, Semigroup, Show} +import cats.data.Nullable +import cats.data.Nullable.* +import cats.kernel.laws.discipline.{EqTests, HashTests, MonoidTests, OrderTests, PartialOrderTests, SemigroupTests} +import cats.laws.discipline.{FoldableTests, SerializableTests} +import cats.syntax.eq.* +import org.scalacheck.{Arbitrary, Cogen, Gen} +import org.scalacheck.Prop.* + +class NullableSuite extends CatsSuite { + + private type NestedNullable[A] = Nullable[Nullable[A]] + + private given Foldable[NestedNullable] = Foldable[Nullable].compose[Nullable] + + given [A](using A: Arbitrary[A]): Arbitrary[Nullable[A]] = + Arbitrary(Gen.oneOf(Gen.const(Nullable.empty[A]), A.arbitrary.map(Nullable(_)))) + + given [A](using A: Cogen[A]): Cogen[Nullable[A]] = + Cogen[Option[A]].contramap(_.toOption) + + checkAll("Nullable[Int]", EqTests[Nullable[Int]].eqv) + checkAll("Nullable[Nullable[Int]]", EqTests[NestedNullable[Int]].eqv) + checkAll("Eq[Nullable[Int]]", SerializableTests.serializable(Eq[Nullable[Int]])) + checkAll("Eq[Nullable[Nullable[Int]]]", SerializableTests.serializable(Eq[NestedNullable[Int]])) + + checkAll("Nullable[Int]", HashTests[Nullable[Int]].hash) + checkAll("Nullable[Nullable[Int]]", HashTests[NestedNullable[Int]].hash) + checkAll("Hash[Nullable[Int]]", SerializableTests.serializable(Hash[Nullable[Int]])) + checkAll("Hash[Nullable[Nullable[Int]]]", SerializableTests.serializable(Hash[NestedNullable[Int]])) + + checkAll("Nullable[Int]", PartialOrderTests[Nullable[Int]].partialOrder) + checkAll("Nullable[Nullable[Int]]", PartialOrderTests[NestedNullable[Int]].partialOrder) + checkAll("PartialOrder[Nullable[Int]]", SerializableTests.serializable(PartialOrder[Nullable[Int]])) + checkAll( + "PartialOrder[Nullable[Nullable[Int]]]", + SerializableTests.serializable(PartialOrder[NestedNullable[Int]]) + ) + + checkAll("Nullable[Int]", OrderTests[Nullable[Int]].order) + checkAll("Nullable[Nullable[Int]]", OrderTests[NestedNullable[Int]].order) + checkAll("Order[Nullable[Int]]", SerializableTests.serializable(Order[Nullable[Int]])) + checkAll("Order[Nullable[Nullable[Int]]]", SerializableTests.serializable(Order[NestedNullable[Int]])) + + checkAll("Nullable[Int]", SemigroupTests[Nullable[Int]].semigroup) + checkAll("Semigroup[Nullable[Int]]", SerializableTests.serializable(Semigroup[Nullable[Int]])) + + checkAll("Nullable[Int]", MonoidTests[Nullable[Int]].monoid) + checkAll("Nullable[Nullable[Int]]", MonoidTests[NestedNullable[Int]].monoid) + checkAll("Monoid[Nullable[Int]]", SerializableTests.serializable(Monoid[Nullable[Int]])) + checkAll("Monoid[Nullable[Nullable[Int]]]", SerializableTests.serializable(Monoid[NestedNullable[Int]])) + + checkAll("Show[Nullable[Int]]", SerializableTests.serializable(Show[Nullable[Int]])) + checkAll("Show[Nullable[Nullable[Int]]]", SerializableTests.serializable(Show[NestedNullable[Int]])) + + checkAll("Foldable[Nullable]", FoldableTests[Nullable].foldable[Int, Int]) + checkAll("Foldable[Nullable].compose[Nullable]", FoldableTests[NestedNullable].foldable[Int, Int]) + checkAll("Foldable[Nullable]", SerializableTests.serializable(Foldable[Nullable])) + + test("fold") { + val nonEmpty: Nullable[Int] = Nullable(1) + val empty: Nullable[Int] = Nullable.empty + + assert(nonEmpty.fold(0)(_ + 1) === 2) + assert(empty.fold(0)(_ + 1) === 0) + } + + test("fold homomorphism with Option") { + forAll { (n: Nullable[Int], x: Int, fn: Int => Int) => + assert(n.fold(x)(fn) === n.toOption.fold(x)(fn)) + } + } + + test("fold infers union result type") { + val nonEmpty: Nullable[String] = Nullable("long") + val empty: Nullable[String] = Nullable.empty + + val nonEmptyIntOrLong: Int | Long = nonEmpty.fold(0)(_ => 1L) + val emptyIntOrLong: Int | Long = empty.fold(0)(_ => 1L) + + assert(nonEmptyIntOrLong == 1L) + assert(emptyIntOrLong == 0) + } + + test("fromOption / toOption round trip") { + forAll { (opt: Option[Int]) => + assert(Nullable.fromOption(opt).toOption === opt) + } + } + + test("iterator is consistent with Option iterator") { + forAll { (nullable: Nullable[Int]) => + assert(nullable.iterator.toList === nullable.toOption.iterator.toList) + } + } + + test("flatten is consistent with Nullable identity and Option view") { + forAll { (nullable: Nullable[Int]) => + val nested: Nullable[Nullable[Int]] = Nullable(nullable) + assert(nested.flatten === nullable) + assert(nested.flatten.toOption === nullable.toOption) + } + } + + test("flatten collapses nested empty and nested non-empty values") { + val nestedEmpty: Nullable[Nullable[Int]] = Nullable(Nullable.empty[Int]) + assert(nestedEmpty.flatten === Nullable.empty[Int]) + + forAll { (x: Int) => + val nestedNonEmpty: Nullable[Nullable[Int]] = Nullable(Nullable(x)) + assert(nestedNonEmpty.flatten === Nullable(x)) + } + } + + test("flatten removes any number of nested Nullable wrappers down to one layer") { + val tripleEmpty: Nullable[Nullable[Nullable[Int]]] = Nullable(Nullable(Nullable.empty[Int])) + val tripleNonEmpty: Nullable[Nullable[Nullable[Int]]] = Nullable(Nullable(Nullable(1))) + + val flattenedEmpty: Nullable[Int] = tripleEmpty.flatten + val flattenedNonEmpty: Nullable[Int] = tripleNonEmpty.flatten + + assert(flattenedEmpty === Nullable.empty[Int]) + assert(flattenedNonEmpty === Nullable(1)) + } + + test("minimal Functor[Nullable] fails composition when composed with itself") { + val candidate = new Functor[Nullable] { + def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + fa.transform(f) + } + + val composed: Functor[NestedNullable] = candidate.compose[Nullable](using candidate) + + val nested: NestedNullable[Int] = Nullable(Nullable(1)) + val f: Int => String = _ => null + val g: String => Int = _ => 42 + + val lhs = composed.map(composed.map(nested)(f))(g) + val rhs = composed.map(nested)(f.andThen(g)) + + assert(lhs =!= rhs) + } + + test("option-like Applicative is not lawful for nested Nullable") { + val candidate = new Applicative[Nullable] { + def pure[A](x: A): Nullable[A] = Nullable(x) + + override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + fa.transform(f) + + def ap[A, B](ff: Nullable[A => B])(fa: Nullable[A]): Nullable[B] = + ff.fold(Nullable.empty[B])(f => fa.transform(f)) + } + + val a: Nullable[Int] = Nullable.empty + val f: Nullable[Int] => Int = _ => 1 + + val lhs = candidate.ap(candidate.pure(f))(candidate.pure(a)) + val rhs = candidate.pure(f(a)) + + assert(lhs =!= rhs) + } + + test("option-like Monad is not lawful for nested Nullable") { + val candidate = new Monad[Nullable] { + def pure[A](x: A): Nullable[A] = Nullable(x) + + override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + fa.transform(f) + + def flatMap[A, B](fa: Nullable[A])(f: A => Nullable[B]): Nullable[B] = + fa.fold(Nullable.empty[B])(f) + + def tailRecM[A, B](a: A)(f: A => Nullable[Either[A, B]]): Nullable[B] = { + def loop(current: A): Nullable[B] = + f(current).fold(Nullable.empty[B]) { + case Left(next) => loop(next) + case Right(b) => Nullable(b) + } + + loop(a) + } + } + + val a: Nullable[Int] = Nullable.empty + val f: Nullable[Int] => Nullable[Int] = _ => Nullable(1) + + val lhs = candidate.flatMap(candidate.pure(a))(f) + val rhs = f(a) + + assert(lhs =!= rhs) + } + + test("current Monoid instance cannot satisfy Group inverse with Int") { + val x: Nullable[Int] = Nullable(1) + val inverseX: Nullable[Int] = Nullable(-1) + + assert(Monoid[Nullable[Int]].combine(x, inverseX) =!= Nullable.empty) + } + + test("Show[Nullable] uses JVM null rendering for null and Show[A] otherwise") { + val empty: Nullable[Int] = Nullable.empty + val nonEmpty: Nullable[Int] = Nullable(1) + + assert(Show[Nullable[Int]].show(empty) === java.lang.String.valueOf(null.asInstanceOf[AnyRef])) + assert(Show[Nullable[Int]].show(nonEmpty) === Show[Int].show(1)) + } + + test("Show[Nullable[Nullable[A]]] is consistent with Show[Nullable[A]]") { + val nestedEmpty: NestedNullable[Int] = Nullable.empty + val nestedNonEmpty: NestedNullable[Int] = Nullable(Nullable(1)) + + assert(Show[NestedNullable[Int]].show(nestedEmpty) === java.lang.String.valueOf(null.asInstanceOf[AnyRef])) + assert(Show[NestedNullable[Int]].show(nestedNonEmpty) === Show[Nullable[Int]].show(Nullable(1))) + } +}