Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add lookupBySubset, and documentation about building the project #20

Merged
merged 9 commits into from
Dec 4, 2024
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
10 changes: 0 additions & 10 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,3 @@ maxColumn = 100
# See Reddit discussion b/w Odersky, Li Haoyi, and Alexandru Nedelcu:
# https://www.reddit.com/r/scala/comments/17qukov/comment/k8hs2pn/
indent.matchSite = 0

rewrite.scala3 {
convertToNewSyntax = yes
removeOptionalBraces {
"enabled": true,
"fewerBracesMinSpan": 2,
"fewerBracesMaxSpan": 50, # Be relatively non-aggressive with braces removal.
"oldSyntaxToo": true,
}
}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v0.3.1] - 2024-12-04

### Added
* `collections.lookupBySubset`
* Documentation for how to build this library

## [v0.3.0] - 2024-11-21

### Changed
Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ lazy val compileSettings = Def.settings(
Test / console / scalacOptions := (Compile / console / scalacOptions).value,
)

lazy val versionNumber = "0.3.0"
lazy val versionNumber = "0.3.1"

lazy val metadataSettings = Def.settings(
name := projectName,
Expand Down
8 changes: 8 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Documentation

## Building the library
This project provides a [Nix shell](../shell.nix) which provides everything you should need to build this library.
From the repository root, ensure you have whatever branch or tag checked out that you want to build, then start the Nix shell with `nix-shell`.

The Nix shell provides `sbt`, which this project uses as its build tool.
Once the Nix shell is active, use `sbt assembly` to build a full JAR with all dependencies bundled inside, and use `sbt publishLocal` to publish this library to your local machine's Ivy repository (useful for then building any project which depends on this one).
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ trait JsonInstancesForCollections:
Try:
v.transform[A](summon[Reader[A]])
.toEither
.leftMap(e => s"Cannot parse value ($v): ${e.getMessage}")
.leftMap(e => s"Cannot parse value ($v): ${e.getMessage}")
}
) match
case (Nil, parsedValues) =>
Expand Down
39 changes: 39 additions & 0 deletions modules/pan/src/main/scala/collections.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package at.ac.oeaw.imba.gerlich.gerlib

import scala.collection.immutable.SortedSet
import cats.*
import cats.syntax.all.*
import cats.data.{NonEmptyList, NonEmptySet}
import io.github.iltotore.iron.{:|, Constraint, refineEither, refineUnsafe}
import io.github.iltotore.iron.constraint.collection.MinLength
Expand Down Expand Up @@ -50,6 +51,14 @@ object collections:
def apply[X](x: X, xs: NonEmptyList[X]): AtLeast2List[X] =
(x :: xs).toList.refineUnsafe[MinLength[2]]

/** Build using List as underlying collection. */
def listOf[X](x1: X, x2: X, xs: X*): AtLeast2List[X] =
listOf(x1, x2, xs.toList)

/** Build using List as underlying collection. */
def listOf[X](x1: X, x2: X, xs: List[X]): AtLeast2List[X] =
unsafe(x1 :: x2 :: xs)

/** Attempt to refine the given collection as one of at least two elements.
*
* @tparam C
Expand Down Expand Up @@ -200,6 +209,36 @@ object collections:
end syntax
end AtLeast2

/** Try to look up a value by unique subset match
*
* The query succeeds if and only if it's the subset (proper or improper) of 'exactly one' "key"
* in the given "mapping" (a list, but functionally representing key-value pairs).
*
* Otherwise, the query fails, and it can fail in either of two ways. The 'more' "severe" failure
* is one in which 'multiple' keys are supersets of the given query set; this results in a
* [[scala.util.Left]] wrapping the matching keys. The 'less' severe failure is when no key
* matches the given query, and this results in a [[scala.util.Right]]-wrapped empty optional
* value, to provide similar semantics to an ordinary {@code .get} call on a [[scala.collection.immutable.Map]].
*
* @tparam E
* The element type of the sets functioning as keys
* @tparam V
* The type of value functioning as the "value" in the given "mapping"
* @param keyValuePairs
* A collection of pairs of "key" and "value", where each key is itself a collection
* @return
* A function which attempts to look up the value for a given collection, with a "hit" occuring
* when the given query set is a subset of one of the key sets in the initial collection
*/
def lookupBySubset[E, V](
keyValuePairs: List[(Set[E], V)]
): Set[E] => Either[AtLeast2[List, (Set[E], V)], Option[V]] =
elements =>
keyValuePairs.filter { (group, _) => elements.subsetOf(group) } match
case Nil => None.asRight
case (_, v) :: Nil => v.some.asRight
case h1 :: h2 :: rest => AtLeast2(h1, NonEmptyList(h2, rest)).asLeft

extension [A](bag: Set[A])
/** Negation of inclusion/membership test result */
def excludes(a: A): Boolean = !bag.contains(a)
Expand Down
51 changes: 51 additions & 0 deletions modules/pan/src/test/scala/TestLookupBySubset.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package at.ac.oeaw.imba.gerlich.gerlib

import cats.syntax.all.*
import org.scalacheck.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import at.ac.oeaw.imba.gerlich.gerlib.collections.{AtLeast2, lookupBySubset}

/** Tests for the {@code collections.lookupBySubset} function */
class TestLookupBySubset extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers:
test("When search pool is empty, result is always empty."):
forAll: (query: Set[Int]) =>
lookupBySubset(List())(query) shouldEqual None.asRight

test("When search pool has exactly one entry, search never yields error (multi-hit) result."):
forAll: (singlePoolEntry: (Set[Int], String), query: Set[Int]) =>
lookupBySubset(List(singlePoolEntry))(query) match
case Right(_) => succeed
case Left(multiHit) =>
fail(s"Single-entry search pool yielded multi-hit result for query $query: $multiHit")

test(
"Empty query yields empty result when pool is empty, good result when pool size is one, error result when pool is multiple."
):
forAll: (searchPool: List[(Set[Int], String)]) =>
val expectation: Either[AtLeast2[List, (Set[Int], String)], Option[String]] =
searchPool match
case Nil => None.asRight
case (_, v) :: Nil => v.some.asRight
case entry1 :: entry2 :: rest => AtLeast2.listOf(entry1, entry2, rest).asLeft
val observation = lookupBySubset(searchPool)(Set())
observation shouldEqual expectation

test(
"When search pool members form a nested hierarchy, only the biggest entry can be the result of a unique hit."
):
val namedBooleanPowerset: List[(Set[Boolean], String)] = List(
Set(),
Set(false),
Set(true),
Set(false, true)
).fproduct(_.map(_.toString).mkString)

forAll: (query: Set[Boolean]) =>
lookupBySubset(namedBooleanPowerset)(query) match {
case Left(hits) => query =!= Set(false, true) shouldBe true
case Right(None) => fail(s"Empty result for query: $query")
case Right(Some(hit)) => hit shouldEqual "falsetrue"
}
end TestLookupBySubset
Loading