From 07f8a0cc19ee3b7a5e0c45e7edd1f193bbda571b Mon Sep 17 00:00:00 2001 From: Suhas Narreddy Date: Wed, 11 Nov 2026 01:24:33 +0100 Subject: [PATCH] MLIR: unwind a typed list of any value type into a typed column --- docs/maps.md | 5 +- query/ir/common/IRLiteralList.h | 12 +- query/ir/common/IRValueTypes.cpp | 44 +---- query/ir/dialect/db/DBOps.td | 12 +- query/ir/dialect/nl/NLOps.td | 14 +- query/ir/exec/NLExecutor.cpp | 30 ++- storage/CellOrder.cpp | 15 +- storage/CellOrder.h | 6 + storage/columns/Functions.h | 8 +- test/query/ir/map/ListOfMapsTest.cpp | 9 +- test/query/ir/path/CMakeLists.txt | 2 + test/query/ir/path/PathSortKeyTest.cpp | 39 ++++ .../ir/temporal/DateTimeComponentCellTest.cpp | 11 +- .../ir/temporal/DurationComponentCellTest.cpp | 8 +- test/query/ir/unwind/CMakeLists.txt | 2 + test/query/ir/unwind/UnwindTypedListTest.cpp | 187 ++++++++++++++++++ 16 files changed, 335 insertions(+), 69 deletions(-) create mode 100644 test/query/ir/path/PathSortKeyTest.cpp create mode 100644 test/query/ir/unwind/UnwindTypedListTest.cpp diff --git a/docs/maps.md b/docs/maps.md index 31ae3852e4..4d71135412 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -116,8 +116,9 @@ out of the tag. An index takes a cell column the same way, and the result is the `nullable` every index gives. A map held in a list reads through those same two paths, since `xs[0]` hands back a -`!storage.any` whose `MapView` tag is the map. An `UNWIND` of such a list binds its -variable to the same cell, so `UNWIND [{a: 1}] AS m RETURN m.a` reads through one too. The +`!storage.any` whose `MapView` tag is the map. An `UNWIND` of a list of maps binds its +variable to a map column instead, as for every list whose element type is known, so +`UNWIND [{a: 1}] AS m RETURN m.a` reads a map; `UNWIND [{a: 1}, 2] AS m` binds cells. The dotted form `xs[0].a` needs a parser rule of its own (`propertyLookupExpr`), because a `.` after a `]` is otherwise no expression. diff --git a/query/ir/common/IRLiteralList.h b/query/ir/common/IRLiteralList.h index 593a52f409..f22ace1219 100644 --- a/query/ir/common/IRLiteralList.h +++ b/query/ir/common/IRLiteralList.h @@ -11,10 +11,10 @@ namespace db { inline mlir::Type sharedLiteralElementType(mlir::ArrayAttr elements); -// The type one literal list element carries: its own for a typed attribute, and the list -// type its own verdict names for a nested list. A null names none, and an embedding (a -// dense f32 array attr) or a map carries no type a list element is read under. A string -// written in IR text parses with no type of its own, and is a string all the same. +// The type one literal list element carries: its own for a typed attribute, the list type +// its own verdict names for a nested list, a map for a dictionary and an embedding for a +// dense f32 array. A null names none. A string written in IR text parses with no type of +// its own, and is a string all the same. inline mlir::Type literalElementType(mlir::Attribute element) { mlir::MLIRContext* const context = element.getContext(); @@ -24,6 +24,10 @@ inline mlir::Type literalElementType(mlir::Attribute element) { return mlir::NoneType::get(context); } else if (mlir::isa(element)) { return mlir::storage::StringType::get(context); + } else if (mlir::isa(element)) { + return mlir::storage::MapType::get(context); + } else if (mlir::isa(element)) { + return mlir::storage::EmbeddingType::get(context); } const auto typed = mlir::dyn_cast(element); diff --git a/query/ir/common/IRValueTypes.cpp b/query/ir/common/IRValueTypes.cpp index db91d05ab5..e4c8785c1f 100644 --- a/query/ir/common/IRValueTypes.cpp +++ b/query/ir/common/IRValueTypes.cpp @@ -3,6 +3,7 @@ #include "mlir/IR/BuiltinAttributes.h" #include "mlir/IR/Builders.h" +#include "NullLiteralType.h" #include "StorageEnums.h" #include "StorageTypes.h" @@ -10,27 +11,6 @@ using namespace db; -namespace { - -// The list element types an unwind can drain into a column of that very type: the entity -// IDs, the value types a nullable value chunk is laid out for, the schema IDs, and a nested -// list, which drains into a list column one level shallower. An unresolved element, an -// embedding, or the !storage.any a heterogeneous list holds drains as tagged scalars instead - -// none of them names a column shape the drain could fill. -bool drainsToItsOwnElementType(mlir::Type listElement) { - if (mlir::isa(listElement)) { - return true; - } else if (mlir::isa(listElement) || mlir::storage::namesASchemaID(listElement)) { - return true; - } - - const auto intType = mlir::dyn_cast(listElement); - - return intType && (intType.getWidth() == 1 || intType.getWidth() == 64); -} - -} - mlir::Type db::valueTypeToElementType(mlir::OpBuilder& builder, ValueType valueType) { switch (valueType) { case ValueType::Int64: @@ -180,21 +160,17 @@ mlir::Type db::unwoundElementType(mlir::MLIRContext* context, mlir::Type sourceE return sourceElement; } - // The elements of a list whose type is known are that type, so the unwind hands the - // rest of the query a column it can read as one - a node stays a node, an integer an - // integer. Only a list whose elements share no such type drains into the type-erased - // column of tagged scalars. + // A column that spells a null as a value of its own rides a plain chunk, and one of a + // property value type or a schema ID the nullable chunk every value-chunk consumer + // reads. Any other list, one of mixed elements or of nulls alone, drains into tagged + // scalars. const mlir::Type listElement = listType.getElementType(); - if (!drainsToItsOwnElementType(listElement)) { - return mlir::storage::AnyType::get(context); - } - - // An entity ID column spells a null entity as an invalid ID, so an entity rides a - // plain chunk; a value or a nested list, either of which may be a tagged null, rides - // the nullable one every value-chunk consumer dispatches on, as lowerUnwindConst's - // homogeneous list does. - if (mlir::isa(listElement)) { + const bool drainsToANullableChunk = elementTypeToValueType(listElement) != ValueType::Invalid + || mlir::storage::namesASchemaID(listElement); + if (mlir::holdsItsOwnNull(listElement)) { return listElement; + } else if (!drainsToANullableChunk) { + return mlir::storage::AnyType::get(context); } return mlir::storage::NullableType::get(context, listElement); diff --git a/query/ir/dialect/db/DBOps.td b/query/ir/dialect/db/DBOps.td index d31c8422fd..9b5dc7e988 100644 --- a/query/ir/dialect/db/DBOps.td +++ b/query/ir/dialect/db/DBOps.td @@ -364,11 +364,13 @@ def Unwind : TuringOp<"unwind", [Pure, DeclareOpInterfaceMethods:$columns_to_filter); diff --git a/query/ir/dialect/nl/NLOps.td b/query/ir/dialect/nl/NLOps.td index bfb58505a4..920a91a72a 100644 --- a/query/ir/dialect/nl/NLOps.td +++ b/query/ir/dialect/nl/NLOps.td @@ -209,18 +209,20 @@ def Unwind : NLOp<"unwind", [Pure]> { %rows = nl.unwind(%names, {%nodes}) : (!nl.chunk>, !nl.chunk) - -> !nl.iter, !nl.chunk> - nl.for %x, %n in %rows : !nl.iter, !nl.chunk> { - nl.output(%x, %n) : !nl.chunk, !nl.chunk + -> !nl.iter>, !nl.chunk> + nl.for %x, %n in %rows : !nl.iter>, !nl.chunk> { + nl.output(%x, %n) : !nl.chunk>, !nl.chunk } A list cell contributes one row per element, a null cell none, and any other cell the single row it is - a tagged scalar included, so a !storage.any cell that is itself a list unwinds one level deeper. Unlike nl.unwind_const it reads an input chunk, so it nests in the loop that binds it, the way nl.get_out_edges nests in the - loop binding its nodes. The element chunk type follows the source's - a list unwinds - into the type-erased !storage.any chunk, a scalar column into its own - and, like - nl.collect's, is spelled out rather than inferred. + loop binding its nodes. The element chunk type follows the source's + (`unwoundElementType`) - a list of IDs or paths unwinds into a chunk of them, a list + of values into a nullable chunk of their type, any other list into the type-erased + !storage.any chunk, a scalar column into its own - and, like nl.collect's, is + spelled out rather than inferred. }]; // `columns_to_filter`: the variadic carry set, after the fixed source, mirroring diff --git a/query/ir/exec/NLExecutor.cpp b/query/ir/exec/NLExecutor.cpp index c393a7e0b0..d759eab413 100644 --- a/query/ir/exec/NLExecutor.cpp +++ b/query/ir/exec/NLExecutor.cpp @@ -2697,6 +2697,34 @@ int compareContainerColumn(const Column* column, size_t a, size_t b) { return 0; } +// 3-way compare two rows of a path column. The empty path a null is spelled as sorts after +// every path, as compareOptColumn places a null after every value. +int comparePathColumn(const Column* column, size_t a, size_t b) { + const std::vector& raw = static_cast*>(column)->getRaw(); + const GraphPath& pathA = raw[a]; + const GraphPath& pathB = raw[b]; + + const bool aNull = pathA.isNull(); + const bool bNull = pathB.isNull(); + if (aNull || bNull) { + if (aNull && bNull) { + return 0; + } + + return aNull ? 1 : -1; + } + + const std::strong_ordering order = PathView {pathA.getIDs()} <=> PathView {pathB.getIDs()}; + + if (order == std::strong_ordering::less) { + return -1; + } else if (order == std::strong_ordering::greater) { + return 1; + } + + return 0; +} + // The nullable sibling, for a list or a map read out of a property: a row holding none // sorts after every value, as compareOptColumn places a null after every value. template @@ -14236,7 +14264,7 @@ NLCompareFunction NLExecutor::selectCompareFunction(NLChunkKind kind) { break; case NLChunkKind::Path: - throw IRException("A path column cannot be a sort key: a path has no order here"); + return &comparePathColumn; break; case NLChunkKind::DateTime: diff --git a/storage/CellOrder.cpp b/storage/CellOrder.cpp index 42c7e32ec9..0f0f85f9b3 100644 --- a/storage/CellOrder.cpp +++ b/storage/CellOrder.cpp @@ -228,12 +228,8 @@ std::strong_ordering compareValues(const Lhs lhs, const Rhs rhs) { return valueOf(lhs) <=> valueOf(rhs); break; - case CellOrderClass::Path: { - const std::span lhsIDs = valueOf(lhs).getIDs(); - const std::span rhsIDs = valueOf(rhs).getIDs(); - - return std::lexicographical_compare_three_way(lhsIDs.begin(), lhsIDs.end(), rhsIDs.begin(), rhsIDs.end()); - } + case CellOrderClass::Path: + return valueOf(lhs) <=> valueOf(rhs); break; case CellOrderClass::Point: @@ -474,6 +470,13 @@ std::strong_ordering db::operator<=>(const MapView lhs, const MapView rhs) { return std::strong_ordering::equal; } +std::strong_ordering db::operator<=>(const PathView lhs, const PathView rhs) { + const std::span lhsIDs = lhs.getIDs(); + const std::span rhsIDs = rhs.getIDs(); + + return std::lexicographical_compare_three_way(lhsIDs.begin(), lhsIDs.end(), rhsIDs.begin(), rhsIDs.end()); +} + bool db::operator==(const MapView lhs, const MapView rhs) { const std::span lhsEntries = lhs.entries(); const std::span rhsEntries = rhs.entries(); diff --git a/storage/CellOrder.h b/storage/CellOrder.h index 5351d63777..a3fa8e0419 100644 --- a/storage/CellOrder.h +++ b/storage/CellOrder.h @@ -49,6 +49,12 @@ std::strong_ordering operator<=>(ListView lhs, ListView rhs); */ std::strong_ordering operator<=>(MapView lhs, MapView rhs); +/** + * @brief Orders two paths by their IDs in path order, so a path sorts before every longer + * path it starts. + */ +std::strong_ordering operator<=>(PathView lhs, PathView rhs); + /** * @brief Cypher's equality of two cells, lists or maps, at any depth: unknown where a null * meets a value and no other pair differs. == is equivalence instead, under which two nulls diff --git a/storage/columns/Functions.h b/storage/columns/Functions.h index 3d5c3ba293..e9cf90ea9b 100644 --- a/storage/columns/Functions.h +++ b/storage/columns/Functions.h @@ -477,10 +477,10 @@ class MapKeysFunction { std::vector _elements; }; -// The field of an instant a type-erased cell carries, which is what an UNWIND of a list of -// instants hands each row. A cell holding a null answers null, as every function over a -// cell does; one holding no instant is a type error only the row it is in can find out -// about, so it throws. +// The field of an instant a type-erased cell carries, as a map value or an element of a +// mixed list does. A cell holding a null answers null, as every function over a cell does; +// one holding no instant is a type error only the row it is in can find out about, so it +// throws. template class TaggedDateTimeComponentFunction { public: diff --git a/test/query/ir/map/ListOfMapsTest.cpp b/test/query/ir/map/ListOfMapsTest.cpp index 8f1ad10777..46669eec4a 100644 --- a/test/query/ir/map/ListOfMapsTest.cpp +++ b/test/query/ir/map/ListOfMapsTest.cpp @@ -170,6 +170,11 @@ std::string renderCell(const Column* column, size_t row) { return list.has_value() ? renderList(*list) : "null"; } + if (const auto* optMaps = dynamic_cast*>(column)) { + const std::optional& map = (*optMaps)[row]; + return map.has_value() ? renderMap(*map) : "null"; + } + throw TuringException("ListOfMapsTest: unsupported output column type"); } @@ -193,7 +198,7 @@ class CollectingListSink : public NLOutputSink { // A list may hold a map, the way a map already holds a list. The map rides the list as the // one tagged cell it is, so it survives nesting, neighbours of other types and an UNWIND -// that spreads the list back out. +// that spreads the list back out into a map column, or into cells beside other types. class ListOfMapsTest : public TuringTest { protected: void initialize() override { @@ -286,8 +291,6 @@ TEST_F(ListOfMapsTest, holdsAnEmptyMap) { expectRows("RETURN [{}]", {"[{}]"}); } -// An UNWIND spreads the list back out, so each map comes back as the one tagged cell it -// was stored as TEST_F(ListOfMapsTest, unwindsToItsMaps) { expectRows("UNWIND [{a: 1}, {b: 2}] AS x RETURN x", {"{a: 1}", "{b: 2}"}); } diff --git a/test/query/ir/path/CMakeLists.txt b/test/query/ir/path/CMakeLists.txt index cdd72699cf..e1e8a5e1d1 100644 --- a/test/query/ir/path/CMakeLists.txt +++ b/test/query/ir/path/CMakeLists.txt @@ -186,3 +186,5 @@ add_call_v3_test(test_query_ir_walk_read_past_a_scope WalkReadPastAScopeTest.cpp add_ir_tests(test_query_ir_path_in_case PathInCaseTest.cpp) add_ir_tests(test_query_ir_path_under_two_names PathUnderTwoNamesTest.cpp) + +add_call_v3_test(test_query_ir_path_sort_key PathSortKeyTest.cpp) diff --git a/test/query/ir/path/PathSortKeyTest.cpp b/test/query/ir/path/PathSortKeyTest.cpp new file mode 100644 index 0000000000..3c337a6873 --- /dev/null +++ b/test/query/ir/path/PathSortKeyTest.cpp @@ -0,0 +1,39 @@ +#include + +#include +#include + +#include "CallV3Test.h" +#include "StringRowSink.h" + +using namespace turing::test; + +namespace { + +using Rows = std::vector; + +} + +// simpledb's KNOWS_WELL edges are Remy (0) -> Adam (1) and Adam -> Remy, so a path from Remy +// sorts before the one from Adam. +class PathSortKeyTest : public CallV3Test { +protected: + void expectRows(const std::string& query, const Rows& expected) { + StringRowSink sink; + runQuery(query, sink); + + EXPECT_EQ(sink.getRows(), expected) << query; + } +}; + +TEST_F(PathSortKeyTest, ordersMatchedPaths) { + expectRows("MATCH p = (a:Person)-[:KNOWS_WELL]->(b:Person) RETURN a.name ORDER BY p", + {{"Remy"}, {"Adam"}}); + expectRows("MATCH p = (a:Person)-[:KNOWS_WELL]->(b:Person) RETURN a.name ORDER BY p DESC", + {{"Adam"}, {"Remy"}}); +} + +TEST_F(PathSortKeyTest, ordersAShorterPathBeforeTheLongerOneItStarts) { + expectRows("MATCH p = (a:Person {name: 'Remy'})-[:KNOWS_WELL*1..2]->(b) RETURN length(p) ORDER BY p", + {{"1"}, {"2"}}); +} diff --git a/test/query/ir/temporal/DateTimeComponentCellTest.cpp b/test/query/ir/temporal/DateTimeComponentCellTest.cpp index 3300ad0062..7cb382ca22 100644 --- a/test/query/ir/temporal/DateTimeComponentCellTest.cpp +++ b/test/query/ir/temporal/DateTimeComponentCellTest.cpp @@ -24,9 +24,8 @@ using namespace db; using namespace turing::test; -// An UNWIND hands its elements on as type-erased cells, which carry their type per row -// rather than per column. A calendar field read off one has to read the instant out of the -// cell, as size() and id() read a list and an entity out of one. +// A calendar field read off an unwound instant, and off a type-erased cell, which carries +// its type per row rather than per column: a map value or a list element. class DateTimeComponentCellTest : public TuringTest { protected: void initialize() override { @@ -143,3 +142,9 @@ TEST_F(DateTimeComponentCellTest, filtersOnAFieldOfAnUnwoundInstant) { "WITH d WHERE d.year > 2020 RETURN d.month", {{"1"}}); } + +TEST_F(DateTimeComponentCellTest, readsAFieldOfAnInstantHeldInACell) { + expectRows("WITH {a: datetime('2026-01-15T00:00:00Z')} AS m, [datetime('2019-03-02T00:00:00Z'), 1] AS l " + "RETURN m.a.year, l[0].month", + {{"2026", "3"}}); +} diff --git a/test/query/ir/temporal/DurationComponentCellTest.cpp b/test/query/ir/temporal/DurationComponentCellTest.cpp index 9bd57d4193..4ecf2716f3 100644 --- a/test/query/ir/temporal/DurationComponentCellTest.cpp +++ b/test/query/ir/temporal/DurationComponentCellTest.cpp @@ -24,7 +24,7 @@ using namespace db; using namespace turing::test; -// A unit read off a duration an UNWIND handed on as a type-erased cell. +// A unit read off an unwound duration, and off a duration a type-erased cell holds. class DurationComponentCellTest : public TuringTest { protected: void initialize() override { @@ -145,3 +145,9 @@ TEST_F(DurationComponentCellTest, readsARemainderOfAnUnwoundDuration) { "RETURN d.minutesOfHour, d.secondsOfMinute", {{"1", "1"}, {"-1", "-30"}, {"null", "null"}}); } + +TEST_F(DurationComponentCellTest, readsAUnitOfADurationHeldInACell) { + expectRows("WITH {a: duration(90061000000)} AS m, [duration(2000000), 'x'] AS l " + "RETURN m.a.hours, l[0].seconds", + {{"25", "2"}}); +} diff --git a/test/query/ir/unwind/CMakeLists.txt b/test/query/ir/unwind/CMakeLists.txt index 4b3f4343b5..900da1d585 100644 --- a/test/query/ir/unwind/CMakeLists.txt +++ b/test/query/ir/unwind/CMakeLists.txt @@ -62,3 +62,5 @@ add_call_v3_test(test_query_ir_unwind_computed UnwindComputedTest.cpp) add_turing_test(test_query_ir_unwind_computed_codegen UnwindComputedCodegenTest.cpp) target_include_directories(test_query_ir_unwind_computed_codegen PRIVATE ${IR_TEST_DIR}) + +add_call_v3_test(test_query_ir_unwind_typed_list UnwindTypedListTest.cpp) diff --git a/test/query/ir/unwind/UnwindTypedListTest.cpp b/test/query/ir/unwind/UnwindTypedListTest.cpp new file mode 100644 index 0000000000..3066fb8989 --- /dev/null +++ b/test/query/ir/unwind/UnwindTypedListTest.cpp @@ -0,0 +1,187 @@ +#include + +#include +#include +#include +#include +#include + +#include "CallV3Test.h" +#include "StringRowSink.h" + +using namespace turing::test; + +namespace { + +using Rows = std::vector; + +} + +class UnwindTypedListTest : public CallV3Test { +protected: + void expectSortedRows(const std::string& query, const Rows& expected) { + StringRowSink sink; + runQuery(query, sink); + + Rows rows = sink.getRows(); + std::sort(rows.begin(), rows.end()); + + Rows sortedExpected = expected; + std::sort(sortedExpected.begin(), sortedExpected.end()); + + EXPECT_EQ(rows, sortedExpected) << query; + } + + void expectRows(const std::string& query, const Rows& expected) { + StringRowSink sink; + runQuery(query, sink); + + EXPECT_EQ(sink.getRows(), expected) << query; + } + + void expectUnwindsInto(const std::string& query, std::string_view elementType) { + StringRowSink sink; + runQuery("EXPLAIN (nl) " + query, sink); + + const Rows& rows = sink.getRows(); + ASSERT_EQ(rows.size(), 1u); + + const std::string& program = rows.front().back(); + const std::string typedIterator = "!nl.iter"; + + // The nl.for over an unwind's result spells the result type, whichever form the + // unwind takes; nl.unwind_computed spells it after its region. + std::string unwindTypes; + std::string loopOverUnwind; + std::istringstream lines(program); + for (std::string line; std::getline(lines, line);) { + const size_t assignment = line.find(" = nl.unwind"); + if (assignment != std::string::npos) { + const size_t nameStart = line.find('%'); + loopOverUnwind = " in " + line.substr(nameStart, assignment - nameStart) + " :"; + } else if (!loopOverUnwind.empty() && line.contains("nl.for") && line.contains(loopOverUnwind)) { + unwindTypes += line + "\n"; + loopOverUnwind.clear(); + } + } + + EXPECT_TRUE(std::string_view(unwindTypes).contains(typedIterator)) << query << "\n" << program; + EXPECT_FALSE(std::string_view(unwindTypes).contains("!nl.iter")) << query << "\n" << program; + } + + void writeTasks() { + runWrite("CREATE (n:Task {name: 'a', took: duration(2000000), at: datetime('2024-03-01T10:00:00Z'), " + "where: point({x: 1.5, y: 2.5}), meta: {rank: 1}})"); + runWrite("CREATE (n:Task {name: 'b', took: duration(90061000000), at: datetime('2025-07-04T08:30:00Z'), " + "where: point({x: 3.5, y: 4.5}), meta: {rank: 2}})"); + } +}; + +TEST_F(UnwindTypedListTest, unwindsDateTimesIntoADateTimeColumn) { + const std::string query = "UNWIND [datetime('2024-03-01T10:00:00Z'), datetime('2025-07-04T08:30:00Z')] AS d " + "RETURN d.year, d.month"; + + expectSortedRows(query, {{"2024", "3"}, {"2025", "7"}}); + expectUnwindsInto(query, "!storage.nullable"); +} + +TEST_F(UnwindTypedListTest, unwindsDurationsIntoADurationColumn) { + const std::string query = "UNWIND [duration(1000000), duration(90061000000)] AS d RETURN d.hours, d.seconds"; + + expectSortedRows(query, {{"0", "1"}, {"25", "90061"}}); + expectUnwindsInto(query, "!storage.nullable"); +} + +TEST_F(UnwindTypedListTest, unwindsADurationAndANullIntoADurationColumn) { + const std::string query = "UNWIND [duration(2000000), null] AS d RETURN d.seconds"; + + expectSortedRows(query, {{"2"}, {"null"}}); + expectUnwindsInto(query, "!storage.nullable"); +} + +TEST_F(UnwindTypedListTest, unwindsMapsIntoAMapColumn) { + const std::string query = "UNWIND [{a: 1}, {a: 2}] AS m RETURN m.a"; + + expectSortedRows(query, {{"1"}, {"2"}}); + expectUnwindsInto(query, "!storage.nullable"); +} + +TEST_F(UnwindTypedListTest, unwindsPointsIntoAPointColumn) { + const std::string query = "UNWIND [point({x: 1.5, y: 2.5}), point({x: 3.5, y: 4.5})] AS p RETURN p.x, p.y"; + + expectSortedRows(query, {{"1.5", "2.5"}, {"3.5", "4.5"}}); + expectUnwindsInto(query, "!storage.nullable"); +} + +TEST_F(UnwindTypedListTest, unwindsCollectedPropertiesIntoTypedColumns) { + writeTasks(); + + const std::string durations = "MATCH (n:Task) WITH collect(n.took) AS ds UNWIND ds AS d RETURN d.seconds"; + expectSortedRows(durations, {{"2"}, {"90061"}}); + expectUnwindsInto(durations, "!storage.nullable"); + + const std::string dateTimes = "MATCH (n:Task) WITH collect(n.at) AS ds UNWIND ds AS d RETURN d.year"; + expectSortedRows(dateTimes, {{"2024"}, {"2025"}}); + expectUnwindsInto(dateTimes, "!storage.nullable"); + + const std::string points = "MATCH (n:Task) WITH collect(n.where) AS ps UNWIND ps AS p RETURN p.y"; + expectSortedRows(points, {{"2.5"}, {"4.5"}}); + expectUnwindsInto(points, "!storage.nullable"); + + const std::string maps = "MATCH (n:Task) WITH collect(n.meta) AS ms UNWIND ms AS m RETURN m.rank"; + expectSortedRows(maps, {{"1"}, {"2"}}); + expectUnwindsInto(maps, "!storage.nullable"); +} + +TEST_F(UnwindTypedListTest, ordersAndDeduplicatesUnwoundTemporalValues) { + expectRows("UNWIND [duration(3000000), duration(1000000), duration(3000000)] AS d " + "RETURN DISTINCT d.seconds AS s ORDER BY s", + {{"1"}, {"3"}}); + expectRows("UNWIND [datetime('2025-01-01T00:00:00Z'), datetime('2024-01-01T00:00:00Z')] AS d " + "WITH d ORDER BY d RETURN d.year", + {{"2024"}, {"2025"}}); + expectRows("UNWIND [duration(3000000), duration(1000000), duration(3000000)] AS d " + "WITH d, count(*) AS c ORDER BY d RETURN d.seconds, c", + {{"1", "1"}, {"3", "2"}}); +} + +TEST_F(UnwindTypedListTest, comparesUnwoundDateTimes) { + expectSortedRows("UNWIND [datetime('2024-03-01T10:00:00Z'), datetime('2025-07-04T08:30:00Z')] AS d " + "WITH d WHERE d > datetime('2025-01-01T00:00:00Z') RETURN d.year", + {{"2025"}}); +} + +TEST_F(UnwindTypedListTest, addsADurationToUnwoundDateTimes) { + expectSortedRows("UNWIND [datetime('2024-03-01T10:00:00Z'), datetime('2025-07-04T08:30:00Z')] AS d " + "RETURN (d + duration(86400000000)).day", + {{"2"}, {"5"}}); +} + +TEST_F(UnwindTypedListTest, unwindsEmbeddingsIntoAnEmbeddingColumn) { + const std::string query = "UNWIND [(1, 5), (2, 3)] AS e RETURN e"; + + expectSortedRows(query, {{"1, 5"}, {"2, 3"}}); + expectUnwindsInto(query, "!storage.nullable"); +} + +TEST_F(UnwindTypedListTest, unwindsCollectedPathsIntoAPathColumn) { + const std::string query = "MATCH p = (a:Person {name: 'Remy'})-[:KNOWS_WELL]->(b) WITH collect(p) AS ps " + "UNWIND ps AS q RETURN length(q), nodes(q)[1].name"; + + expectSortedRows(query, {{"1", "Adam"}}); + expectUnwindsInto(query, "!storage.path"); +} + +TEST_F(UnwindTypedListTest, unwindsAPathAndANullIntoAPathColumn) { + const std::string query = "MATCH p = (a:Person {name: 'Remy'})-[:KNOWS_WELL]->(b) " + "UNWIND [p, null] AS q RETURN q IS NULL, length(q)"; + + expectSortedRows(query, {{"false", "1"}, {"true", "null"}}); + expectUnwindsInto(query, "!storage.path"); +} + +TEST_F(UnwindTypedListTest, ordersUnwoundPaths) { + expectRows("MATCH p = (a:Person {name: 'Remy'})-[:KNOWS_WELL]->(b) " + "UNWIND [p, null] AS q WITH q ORDER BY q RETURN length(q)", + {{"1"}, {"null"}}); +}