diff --git a/dsl-template/src/commonMain/kotlin/path/Path.kt b/dsl-template/src/commonMain/kotlin/path/Path.kt index e6b82005..05369cf7 100644 --- a/dsl-template/src/commonMain/kotlin/path/Path.kt +++ b/dsl-template/src/commonMain/kotlin/path/Path.kt @@ -123,7 +123,7 @@ data class Path( * For example, the path `Path(PathSegment.Indexed(5), Path(PathSegment.Field("foo"), null))` represents the path `"foo.5"`. */ val parent: Path?, -) { +) : Comparable { @LowLevelApi private suspend fun SequenceScope.buildSequence(current: Path) { @@ -141,6 +141,70 @@ data class Path( fun asSequence(): Sequence = sequence { buildSequence(this@Path) } + /** + * Compares this path with another path. + * + * The paths are compared based on order when walking a filesystem: + * - The paths are compared by their length, with shorter paths being considered "less than" longer paths. + * - If the paths have the same length, they are compared by their segments, in order. The comparison is done by comparing the + * segment types first, and then the segment values (in alphanumérical order). + * + * Therefore, this is a possible order: + * ```text + * a + * a.b + * a.foo + * a.foo.bar + * a.foo.baz + * d + * d.1 + * d.2 + * ``` + */ + override fun compareTo(other: Path): Int { + val thisIter = asSequence().iterator() + val otherIter = other.asSequence().iterator() + + while (thisIter.hasNext() && otherIter.hasNext()) { + val thisValue = thisIter.next() + val otherValue = otherIter.next() + + if (thisValue == otherValue) + continue + + fun scoreOfType(segment: PathSegment): Int = when (segment) { + PathSegment.AllPositional -> 0 + PathSegment.Positional -> 1 + is PathSegment.Field -> 2 + is PathSegment.FilteredPositional -> 3 + is PathSegment.Indexed -> 4 + } + + val thisTypeScore = scoreOfType(thisValue) + val otherTypeScore = scoreOfType(otherValue) + + if (thisTypeScore != otherTypeScore) { + return thisTypeScore - otherTypeScore + } + + // At this point, both are guaranteed to be the same type but with different values + return when (thisValue) { + PathSegment.AllPositional -> error("Impossible situation: both paths have a segment of type ${PathSegment.AllPositional::class} that are not equal; comparing $this and $other") + is PathSegment.Field -> thisValue.name.compareTo((otherValue as PathSegment.Field).name) + is PathSegment.FilteredPositional -> thisValue.filterName.compareTo((otherValue as PathSegment.FilteredPositional).filterName) + is PathSegment.Indexed -> thisValue.index.compareTo((otherValue as PathSegment.Indexed).index) + PathSegment.Positional -> error("Impossible situation: both paths have a segment of type ${PathSegment.Positional::class} that are not equal; comparing $this and $other") + } + } + + // We reach here if they don't have the same size, but all the first segments are the same + return when { + thisIter.hasNext() -> 1 + otherIter.hasNext() -> -1 + else -> 0 // Same number of segments, which are each identical + } + } + /** * Returns the string representation of this [Path]. This is the representation that is sent to MongoDB to refer to a [Field]. * diff --git a/dsl/src/commonMain/kotlin/path/Path.kt b/dsl/src/commonMain/kotlin/path/Path.kt index 33a3fba0..447d2247 100644 --- a/dsl/src/commonMain/kotlin/path/Path.kt +++ b/dsl/src/commonMain/kotlin/path/Path.kt @@ -126,7 +126,7 @@ data class Path( * For example, the path `Path(PathSegment.Indexed(5), Path(PathSegment.Field("foo"), null))` represents the path `"foo.5"`. */ val parent: Path?, -) { +) : Comparable { @LowLevelApi private suspend fun SequenceScope.buildSequence(current: Path) { @@ -144,6 +144,70 @@ data class Path( fun asSequence(): Sequence = sequence { buildSequence(this@Path) } + /** + * Compares this path with another path. + * + * The paths are compared based on order when walking a filesystem: + * - The paths are compared by their length, with shorter paths being considered "less than" longer paths. + * - If the paths have the same length, they are compared by their segments, in order. The comparison is done by comparing the + * segment types first, and then the segment values (in alphanumérical order). + * + * Therefore, this is a possible order: + * ```text + * a + * a.b + * a.foo + * a.foo.bar + * a.foo.baz + * d + * d.1 + * d.2 + * ``` + */ + override fun compareTo(other: Path): Int { + val thisIter = asSequence().iterator() + val otherIter = other.asSequence().iterator() + + while (thisIter.hasNext() && otherIter.hasNext()) { + val thisValue = thisIter.next() + val otherValue = otherIter.next() + + if (thisValue == otherValue) + continue + + fun scoreOfType(segment: PathSegment): Int = when (segment) { + PathSegment.AllPositional -> 0 + PathSegment.Positional -> 1 + is PathSegment.Field -> 2 + is PathSegment.FilteredPositional -> 3 + is PathSegment.Indexed -> 4 + } + + val thisTypeScore = scoreOfType(thisValue) + val otherTypeScore = scoreOfType(otherValue) + + if (thisTypeScore != otherTypeScore) { + return thisTypeScore - otherTypeScore + } + + // At this point, both are guaranteed to be the same type but with different values + return when (thisValue) { + PathSegment.AllPositional -> error("Impossible situation: both paths have a segment of type ${PathSegment.AllPositional::class} that are not equal; comparing $this and $other") + is PathSegment.Field -> thisValue.name.compareTo((otherValue as PathSegment.Field).name) + is PathSegment.FilteredPositional -> thisValue.filterName.compareTo((otherValue as PathSegment.FilteredPositional).filterName) + is PathSegment.Indexed -> thisValue.index.compareTo((otherValue as PathSegment.Indexed).index) + PathSegment.Positional -> error("Impossible situation: both paths have a segment of type ${PathSegment.Positional::class} that are not equal; comparing $this and $other") + } + } + + // We reach here if they don't have the same size, but all the first segments are the same + return when { + thisIter.hasNext() -> 1 + otherIter.hasNext() -> -1 + else -> 0 // Same number of segments, which are each identical + } + } + /** * Returns the string representation of this [Path]. This is the representation that is sent to MongoDB to refer to a [Field]. * diff --git a/dsl/src/commonTest/kotlin/path/PathTest.kt b/dsl/src/commonTest/kotlin/path/PathTest.kt index b7f8aac8..98db6762 100644 --- a/dsl/src/commonTest/kotlin/path/PathTest.kt +++ b/dsl/src/commonTest/kotlin/path/PathTest.kt @@ -24,28 +24,101 @@ import opensavvy.ktmongo.dsl.path.PathSegment.Field @OptIn(LowLevelApi::class) val PathTest by multiContextSuite { - test("Root field") { - check(Path("test").toString() == "test") - } + suite("String representation") { + test("Root field") { + check(Path("test").toString() == "test") + } - test("Nested field") { - check((Path("test") / Field("bar")).toString() == "test.bar") - } + test("Nested field") { + check((Path("test") / Field("bar")).toString() == "test.bar") + } - test("Deeper nested field") { - check((Path("test") / Field("bar") / Field("foo")).toString() == "test.bar.foo") - } + test("Deeper nested field") { + check((Path("test") / Field("bar") / Field("foo")).toString() == "test.bar.foo") + } - test("Indexed") { - check((Path("test") / Indexed(3) / Field("bar")).toString() == "test.3.bar") - } + test("Indexed") { + check((Path("test") / Indexed(3) / Field("bar")).toString() == "test.3.bar") + } - test("Positional") { - check((Path("test") / Positional / Field("bar")).toString() == "test.$.bar") + test("Positional") { + check((Path("test") / Positional / Field("bar")).toString() == "test.$.bar") + } + + test("All positional") { + check((Path("test") / AllPositional / Field("bar")).toString() == "test.$[].bar") + } } - test("All positional") { - check((Path("test") / AllPositional / Field("bar")).toString() == "test.$[].bar") + suite("Comparison") { + test("A path is equal to itself") { + check(Path("a").compareTo(Path("a")) == 0) + check((Path("a") / Field("b")).compareTo(Path("a") / Field("b")) == 0) + } + + test("Shorter paths are less than longer paths sharing the same prefix") { + check(Path("a") < Path("a") / Field("b")) + check((Path("a") / Field("b")) > Path("a")) + } + + test("Fields are compared alphanumerically") { + check((Path("a") / Field("b")) < Path("a") / Field("foo")) + check((Path("a") / Field("foo")) > Path("a") / Field("b")) + } + + test("Indexed segments are compared numerically") { + check((Path("d") / Indexed(1)) < Path("d") / Indexed(2)) + check((Path("d") / Indexed(2)) > Path("d") / Indexed(1)) + } + + test("FilteredPositional segments are compared alphanumerically by filter name") { + check((Path("a") / FilteredPositional("x")) < Path("a") / FilteredPositional("y")) + check((Path("a") / FilteredPositional("y")) > Path("a") / FilteredPositional("x")) + } + + test("Segment types are ordered: AllPositional < Positional < Field < FilteredPositional < Indexed") { + check((Path("a") / AllPositional) < Path("a") / Positional) + check((Path("a") / Positional) < Path("a") / Field("b")) + check((Path("a") / Field("b")) < Path("a") / FilteredPositional("x")) + check((Path("a") / FilteredPositional("x")) < Path("a") / Indexed(0)) + + // And in reverse + check((Path("a") / Indexed(0)) > Path("a") / FilteredPositional("x")) + check((Path("a") / FilteredPositional("x")) > Path("a") / Field("b")) + check((Path("a") / Field("b")) > Path("a") / Positional) + check((Path("a") / Positional) > Path("a") / AllPositional) + } + + test("Common prefixes are skipped before comparing the differing segment") { + check((Path("a") / Field("foo") / Field("bar")) < Path("a") / Field("foo") / Field("baz")) + check((Path("a") / Field("foo")) < Path("d")) + } + + test("Example order from the documentation") { + val paths = listOf( + Path("d") / Indexed(2), + Path("a") / Field("foo") / Field("baz"), + Path("d"), + Path("a"), + Path("a") / Field("foo"), + Path("a") / Field("b"), + Path("d") / Indexed(1), + Path("a") / Field("foo") / Field("bar"), + ) + + val expected = listOf( + Path("a"), + Path("a") / Field("b"), + Path("a") / Field("foo"), + Path("a") / Field("foo") / Field("bar"), + Path("a") / Field("foo") / Field("baz"), + Path("d"), + Path("d") / Indexed(1), + Path("d") / Indexed(2), + ) + + check(paths.sorted() == expected) + } } }