flowquery 1.0.31 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/compute/flowquery.d.ts +43 -0
  2. package/dist/compute/flowquery.d.ts.map +1 -0
  3. package/dist/compute/flowquery.js +30 -0
  4. package/dist/compute/flowquery.js.map +1 -0
  5. package/dist/compute/runner.d.ts +0 -21
  6. package/dist/compute/runner.d.ts.map +1 -1
  7. package/dist/compute/runner.js.map +1 -1
  8. package/dist/flowquery.min.js +1 -1
  9. package/dist/index.browser.d.ts +1 -1
  10. package/dist/index.browser.d.ts.map +1 -1
  11. package/dist/index.browser.js +10 -10
  12. package/dist/index.browser.js.map +1 -1
  13. package/dist/index.node.d.ts +4 -4
  14. package/dist/index.node.d.ts.map +1 -1
  15. package/dist/index.node.js +13 -13
  16. package/dist/index.node.js.map +1 -1
  17. package/dist/parsing/context.d.ts +1 -0
  18. package/dist/parsing/context.d.ts.map +1 -1
  19. package/dist/parsing/context.js +5 -0
  20. package/dist/parsing/context.js.map +1 -1
  21. package/dist/parsing/expressions/operator.d.ts +2 -2
  22. package/dist/parsing/expressions/operator.d.ts.map +1 -1
  23. package/dist/parsing/expressions/operator.js +6 -1
  24. package/dist/parsing/expressions/operator.js.map +1 -1
  25. package/dist/parsing/operations/group_by.d.ts.map +1 -1
  26. package/dist/parsing/operations/group_by.js +8 -4
  27. package/dist/parsing/operations/group_by.js.map +1 -1
  28. package/dist/parsing/operations/match.d.ts +5 -1
  29. package/dist/parsing/operations/match.d.ts.map +1 -1
  30. package/dist/parsing/operations/match.js +25 -1
  31. package/dist/parsing/operations/match.js.map +1 -1
  32. package/dist/parsing/operations/union.d.ts +36 -0
  33. package/dist/parsing/operations/union.d.ts.map +1 -0
  34. package/dist/parsing/operations/union.js +121 -0
  35. package/dist/parsing/operations/union.js.map +1 -0
  36. package/dist/parsing/operations/union_all.d.ts +10 -0
  37. package/dist/parsing/operations/union_all.d.ts.map +1 -0
  38. package/dist/parsing/operations/union_all.js +17 -0
  39. package/dist/parsing/operations/union_all.js.map +1 -0
  40. package/dist/parsing/parser.d.ts +2 -3
  41. package/dist/parsing/parser.d.ts.map +1 -1
  42. package/dist/parsing/parser.js +72 -24
  43. package/dist/parsing/parser.js.map +1 -1
  44. package/dist/parsing/parser_state.d.ts +13 -0
  45. package/dist/parsing/parser_state.d.ts.map +1 -0
  46. package/dist/parsing/parser_state.js +27 -0
  47. package/dist/parsing/parser_state.js.map +1 -0
  48. package/dist/tokenization/keyword.d.ts +4 -1
  49. package/dist/tokenization/keyword.d.ts.map +1 -1
  50. package/dist/tokenization/keyword.js +3 -0
  51. package/dist/tokenization/keyword.js.map +1 -1
  52. package/dist/tokenization/token.d.ts +6 -0
  53. package/dist/tokenization/token.d.ts.map +1 -1
  54. package/dist/tokenization/token.js +18 -0
  55. package/dist/tokenization/token.js.map +1 -1
  56. package/docs/flowquery.min.js +1 -1
  57. package/flowquery-py/pyproject.toml +1 -1
  58. package/flowquery-py/src/__init__.py +2 -0
  59. package/flowquery-py/src/compute/__init__.py +2 -1
  60. package/flowquery-py/src/compute/flowquery.py +68 -0
  61. package/flowquery-py/src/graph/node.py +1 -1
  62. package/flowquery-py/src/parsing/operations/__init__.py +4 -0
  63. package/flowquery-py/src/parsing/operations/group_by.py +3 -0
  64. package/flowquery-py/src/parsing/operations/match.py +24 -2
  65. package/flowquery-py/src/parsing/operations/union.py +115 -0
  66. package/flowquery-py/src/parsing/operations/union_all.py +17 -0
  67. package/flowquery-py/src/parsing/parser.py +68 -24
  68. package/flowquery-py/src/parsing/parser_state.py +26 -0
  69. package/flowquery-py/src/tokenization/keyword.py +3 -0
  70. package/flowquery-py/src/tokenization/token.py +21 -0
  71. package/flowquery-py/tests/compute/test_runner.py +557 -1
  72. package/flowquery-py/tests/parsing/test_parser.py +82 -0
  73. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  74. package/package.json +1 -1
  75. package/src/compute/flowquery.ts +46 -0
  76. package/src/compute/runner.ts +0 -24
  77. package/src/index.browser.ts +17 -14
  78. package/src/index.node.ts +21 -18
  79. package/src/parsing/context.ts +6 -0
  80. package/src/parsing/expressions/operator.ts +8 -3
  81. package/src/parsing/operations/group_by.ts +27 -19
  82. package/src/parsing/operations/match.ts +24 -1
  83. package/src/parsing/operations/union.ts +114 -0
  84. package/src/parsing/operations/union_all.ts +16 -0
  85. package/src/parsing/parser.ts +74 -23
  86. package/src/parsing/parser_state.ts +25 -0
  87. package/src/tokenization/keyword.ts +3 -0
  88. package/src/tokenization/token.ts +24 -0
  89. package/tests/compute/runner.test.ts +481 -0
  90. package/tests/parsing/parser.test.ts +76 -0
@@ -378,6 +378,21 @@ class TestRunner:
378
378
  assert results[0] == {"i": 1, "sum": 12}
379
379
  assert results[1] == {"i": 2, "sum": 12}
380
380
 
381
+ @pytest.mark.asyncio
382
+ async def test_aggregated_with_on_empty_result_set(self):
383
+ """Test aggregated with on empty result set does not crash."""
384
+ runner = Runner(
385
+ """
386
+ unwind [] as i
387
+ unwind [1, 2] as j
388
+ with i, count(j) as cnt
389
+ return i, cnt
390
+ """
391
+ )
392
+ await runner.run()
393
+ results = runner.results
394
+ assert len(results) == 0
395
+
381
396
  @pytest.mark.asyncio
382
397
  async def test_aggregated_with_using_collect_and_return(self):
383
398
  """Test aggregated with using collect and return."""
@@ -1836,6 +1851,236 @@ class TestRunner:
1836
1851
  assert len(results) == 1
1837
1852
  assert results[0]["name"] == "Employee 1"
1838
1853
 
1854
+ @pytest.mark.asyncio
1855
+ async def test_optional_match_with_no_matching_relationship(self):
1856
+ """Test optional match with no matching relationship returns null."""
1857
+ await Runner(
1858
+ """
1859
+ CREATE VIRTUAL (:OptPerson) AS {
1860
+ unwind [
1861
+ {id: 1, name: 'Person 1'},
1862
+ {id: 2, name: 'Person 2'},
1863
+ {id: 3, name: 'Person 3'}
1864
+ ] as record
1865
+ RETURN record.id as id, record.name as name
1866
+ }
1867
+ """
1868
+ ).run()
1869
+ await Runner(
1870
+ """
1871
+ CREATE VIRTUAL (:OptPerson)-[:KNOWS]-(:OptPerson) AS {
1872
+ unwind [
1873
+ {left_id: 1, right_id: 2}
1874
+ ] as record
1875
+ RETURN record.left_id as left_id, record.right_id as right_id
1876
+ }
1877
+ """
1878
+ ).run()
1879
+ # Person 3 has no KNOWS relationship, so OPTIONAL MATCH should return null for friend
1880
+ match = Runner(
1881
+ """
1882
+ MATCH (a:OptPerson)
1883
+ OPTIONAL MATCH (a)-[:KNOWS]->(b:OptPerson)
1884
+ RETURN a.name AS name, b AS friend
1885
+ """
1886
+ )
1887
+ await match.run()
1888
+ results = match.results
1889
+ assert len(results) == 3
1890
+ assert results[0]["name"] == "Person 1"
1891
+ assert results[0]["friend"] is not None
1892
+ assert results[0]["friend"]["name"] == "Person 2"
1893
+ assert results[1]["name"] == "Person 2"
1894
+ assert results[1]["friend"] is None
1895
+ assert results[2]["name"] == "Person 3"
1896
+ assert results[2]["friend"] is None
1897
+
1898
+ @pytest.mark.asyncio
1899
+ async def test_optional_match_where_all_nodes_match(self):
1900
+ """Test optional match where all nodes have matching relationships."""
1901
+ await Runner(
1902
+ """
1903
+ CREATE VIRTUAL (:OptAllPerson) AS {
1904
+ unwind [
1905
+ {id: 1, name: 'Person 1'},
1906
+ {id: 2, name: 'Person 2'}
1907
+ ] as record
1908
+ RETURN record.id as id, record.name as name
1909
+ }
1910
+ """
1911
+ ).run()
1912
+ await Runner(
1913
+ """
1914
+ CREATE VIRTUAL (:OptAllPerson)-[:KNOWS]-(:OptAllPerson) AS {
1915
+ unwind [
1916
+ {left_id: 1, right_id: 2},
1917
+ {left_id: 2, right_id: 1}
1918
+ ] as record
1919
+ RETURN record.left_id as left_id, record.right_id as right_id
1920
+ }
1921
+ """
1922
+ ).run()
1923
+ # All persons have KNOWS relationships, so no null values
1924
+ match = Runner(
1925
+ """
1926
+ MATCH (a:OptAllPerson)
1927
+ OPTIONAL MATCH (a)-[:KNOWS]->(b:OptAllPerson)
1928
+ RETURN a.name AS name, b AS friend
1929
+ """
1930
+ )
1931
+ await match.run()
1932
+ results = match.results
1933
+ assert len(results) == 2
1934
+ assert results[0]["name"] == "Person 1"
1935
+ assert results[0]["friend"]["name"] == "Person 2"
1936
+ assert results[1]["name"] == "Person 2"
1937
+ assert results[1]["friend"]["name"] == "Person 1"
1938
+
1939
+ @pytest.mark.asyncio
1940
+ async def test_optional_match_with_no_data_returns_nulls(self):
1941
+ """Test optional match with no matching data returns nulls."""
1942
+ await Runner(
1943
+ """
1944
+ CREATE VIRTUAL (:OptNullPerson) AS {
1945
+ unwind [
1946
+ {id: 1, name: 'Person 1'},
1947
+ {id: 2, name: 'Person 2'}
1948
+ ] as record
1949
+ RETURN record.id as id, record.name as name
1950
+ }
1951
+ """
1952
+ ).run()
1953
+ await Runner(
1954
+ """
1955
+ CREATE VIRTUAL (:OptNullPerson)-[:KNOWS]-(:OptNullPerson) AS {
1956
+ unwind [] as record
1957
+ RETURN record.left_id as left_id, record.right_id as right_id
1958
+ }
1959
+ """
1960
+ ).run()
1961
+ # KNOWS relationship type exists but has no data
1962
+ match = Runner(
1963
+ """
1964
+ MATCH (a:OptNullPerson)
1965
+ OPTIONAL MATCH (a)-[:KNOWS]->(b:OptNullPerson)
1966
+ RETURN a.name AS name, b AS friend
1967
+ """
1968
+ )
1969
+ await match.run()
1970
+ results = match.results
1971
+ assert len(results) == 2
1972
+ assert results[0]["name"] == "Person 1"
1973
+ assert results[0]["friend"] is None
1974
+ assert results[1]["name"] == "Person 2"
1975
+ assert results[1]["friend"] is None
1976
+
1977
+ @pytest.mark.asyncio
1978
+ async def test_optional_match_with_aggregation(self):
1979
+ """Test optional match with aggregation (collect friends)."""
1980
+ await Runner(
1981
+ """
1982
+ CREATE VIRTUAL (:OptAggPerson) AS {
1983
+ unwind [
1984
+ {id: 1, name: 'Person 1'},
1985
+ {id: 2, name: 'Person 2'},
1986
+ {id: 3, name: 'Person 3'}
1987
+ ] as record
1988
+ RETURN record.id as id, record.name as name
1989
+ }
1990
+ """
1991
+ ).run()
1992
+ await Runner(
1993
+ """
1994
+ CREATE VIRTUAL (:OptAggPerson)-[:KNOWS]-(:OptAggPerson) AS {
1995
+ unwind [
1996
+ {left_id: 1, right_id: 2},
1997
+ {left_id: 1, right_id: 3}
1998
+ ] as record
1999
+ RETURN record.left_id as left_id, record.right_id as right_id
2000
+ }
2001
+ """
2002
+ ).run()
2003
+ # Collect friends per person; Person 2 and 3 have no friends
2004
+ match = Runner(
2005
+ """
2006
+ MATCH (a:OptAggPerson)
2007
+ OPTIONAL MATCH (a)-[:KNOWS]->(b:OptAggPerson)
2008
+ RETURN a.name AS name, collect(b) AS friends
2009
+ """
2010
+ )
2011
+ await match.run()
2012
+ results = match.results
2013
+ assert len(results) == 3
2014
+ assert results[0]["name"] == "Person 1"
2015
+ assert len(results[0]["friends"]) == 2
2016
+ assert results[1]["name"] == "Person 2"
2017
+ assert len(results[1]["friends"]) == 1 # null is collected
2018
+ assert results[2]["name"] == "Person 3"
2019
+ assert len(results[2]["friends"]) == 1 # null is collected
2020
+
2021
+ @pytest.mark.asyncio
2022
+ async def test_standalone_optional_match_returns_data(self):
2023
+ """Test standalone optional match returns data when label exists."""
2024
+ await Runner(
2025
+ """
2026
+ CREATE VIRTUAL (:OptStandalonePerson) AS {
2027
+ unwind [
2028
+ {id: 1, name: 'Person 1'},
2029
+ {id: 2, name: 'Person 2'}
2030
+ ] as record
2031
+ RETURN record.id as id, record.name as name
2032
+ }
2033
+ """
2034
+ ).run()
2035
+ await Runner(
2036
+ """
2037
+ CREATE VIRTUAL (:OptStandalonePerson)-[:KNOWS]-(:OptStandalonePerson) AS {
2038
+ unwind [
2039
+ {left_id: 1, right_id: 2}
2040
+ ] as record
2041
+ RETURN record.left_id as left_id, record.right_id as right_id
2042
+ }
2043
+ """
2044
+ ).run()
2045
+ # Standalone OPTIONAL MATCH with relationship where only Person 1 has a match
2046
+ match = Runner(
2047
+ """
2048
+ OPTIONAL MATCH (a:OptStandalonePerson)-[:KNOWS]->(b:OptStandalonePerson)
2049
+ RETURN a.name AS name, b.name AS friend
2050
+ """
2051
+ )
2052
+ await match.run()
2053
+ results = match.results
2054
+ assert len(results) == 1
2055
+ assert results[0] == {"name": "Person 1", "friend": "Person 2"}
2056
+
2057
+ @pytest.mark.asyncio
2058
+ async def test_optional_match_returns_full_node_when_matched(self):
2059
+ """Test optional match on existing label returns actual nodes."""
2060
+ await Runner(
2061
+ """
2062
+ CREATE VIRTUAL (:OptFullPerson) AS {
2063
+ unwind [
2064
+ {id: 1, name: 'Person 1'},
2065
+ {id: 2, name: 'Person 2'}
2066
+ ] as record
2067
+ RETURN record.id as id, record.name as name
2068
+ }
2069
+ """
2070
+ ).run()
2071
+ # OPTIONAL MATCH on existing label returns actual nodes
2072
+ match = Runner(
2073
+ """
2074
+ OPTIONAL MATCH (n:OptFullPerson)
2075
+ RETURN n.name AS name
2076
+ """
2077
+ )
2078
+ await match.run()
2079
+ results = match.results
2080
+ assert len(results) == 2
2081
+ assert results[0] == {"name": "Person 1"}
2082
+ assert results[1] == {"name": "Person 2"}
2083
+
1839
2084
  @pytest.mark.asyncio
1840
2085
  async def test_schema_returns_nodes_and_relationships_with_sample_data(self):
1841
2086
  """Test schema() returns nodes and relationships with sample data."""
@@ -2253,4 +2498,315 @@ class TestRunner:
2253
2498
  names = [r["name"] for r in results]
2254
2499
  assert "Person 1" in names
2255
2500
  assert "Person 2" in names
2256
- assert "Person 3" in names
2501
+ assert "Person 3" in names
2502
+
2503
+ # ============================================================
2504
+ # Add operator tests
2505
+ # ============================================================
2506
+
2507
+ @pytest.mark.asyncio
2508
+ async def test_add_two_integers(self):
2509
+ """Test add two integers."""
2510
+ runner = Runner("return 1 + 2 as result")
2511
+ await runner.run()
2512
+ results = runner.results
2513
+ assert len(results) == 1
2514
+ assert results[0] == {"result": 3}
2515
+
2516
+ @pytest.mark.asyncio
2517
+ async def test_add_negative_number(self):
2518
+ """Test add with a negative number."""
2519
+ runner = Runner("return -3 + 7 as result")
2520
+ await runner.run()
2521
+ results = runner.results
2522
+ assert len(results) == 1
2523
+ assert results[0] == {"result": 4}
2524
+
2525
+ @pytest.mark.asyncio
2526
+ async def test_add_to_negative_result(self):
2527
+ """Test add to negative result."""
2528
+ runner = Runner("return 0 - 10 + 4 as result")
2529
+ await runner.run()
2530
+ results = runner.results
2531
+ assert len(results) == 1
2532
+ assert results[0] == {"result": -6}
2533
+
2534
+ @pytest.mark.asyncio
2535
+ async def test_add_zero(self):
2536
+ """Test add zero."""
2537
+ runner = Runner("return 42 + 0 as result")
2538
+ await runner.run()
2539
+ results = runner.results
2540
+ assert len(results) == 1
2541
+ assert results[0] == {"result": 42}
2542
+
2543
+ @pytest.mark.asyncio
2544
+ async def test_add_floating_point_numbers(self):
2545
+ """Test add floating point numbers."""
2546
+ runner = Runner("return 1.5 + 2.3 as result")
2547
+ await runner.run()
2548
+ results = runner.results
2549
+ assert len(results) == 1
2550
+ assert results[0]["result"] == pytest.approx(3.8)
2551
+
2552
+ @pytest.mark.asyncio
2553
+ async def test_add_integer_and_float(self):
2554
+ """Test add integer and float."""
2555
+ runner = Runner("return 1 + 0.5 as result")
2556
+ await runner.run()
2557
+ results = runner.results
2558
+ assert len(results) == 1
2559
+ assert results[0]["result"] == pytest.approx(1.5)
2560
+
2561
+ @pytest.mark.asyncio
2562
+ async def test_add_strings(self):
2563
+ """Test add strings."""
2564
+ runner = Runner('return "hello" + " world" as result')
2565
+ await runner.run()
2566
+ results = runner.results
2567
+ assert len(results) == 1
2568
+ assert results[0] == {"result": "hello world"}
2569
+
2570
+ @pytest.mark.asyncio
2571
+ async def test_add_empty_strings(self):
2572
+ """Test add empty strings."""
2573
+ runner = Runner('return "" + "" as result')
2574
+ await runner.run()
2575
+ results = runner.results
2576
+ assert len(results) == 1
2577
+ assert results[0] == {"result": ""}
2578
+
2579
+ @pytest.mark.asyncio
2580
+ async def test_add_string_and_empty_string(self):
2581
+ """Test add string and empty string."""
2582
+ runner = Runner('return "hello" + "" as result')
2583
+ await runner.run()
2584
+ results = runner.results
2585
+ assert len(results) == 1
2586
+ assert results[0] == {"result": "hello"}
2587
+
2588
+ @pytest.mark.asyncio
2589
+ async def test_add_two_lists(self):
2590
+ """Test add two lists."""
2591
+ runner = Runner("return [1, 2] + [3, 4] as result")
2592
+ await runner.run()
2593
+ results = runner.results
2594
+ assert len(results) == 1
2595
+ assert results[0] == {"result": [1, 2, 3, 4]}
2596
+
2597
+ @pytest.mark.asyncio
2598
+ async def test_add_empty_list_to_list(self):
2599
+ """Test add empty list to list."""
2600
+ runner = Runner("return [1, 2, 3] + [] as result")
2601
+ await runner.run()
2602
+ results = runner.results
2603
+ assert len(results) == 1
2604
+ assert results[0] == {"result": [1, 2, 3]}
2605
+
2606
+ @pytest.mark.asyncio
2607
+ async def test_add_two_empty_lists(self):
2608
+ """Test add two empty lists."""
2609
+ runner = Runner("return [] + [] as result")
2610
+ await runner.run()
2611
+ results = runner.results
2612
+ assert len(results) == 1
2613
+ assert results[0] == {"result": []}
2614
+
2615
+ @pytest.mark.asyncio
2616
+ async def test_add_lists_with_mixed_types(self):
2617
+ """Test add lists with mixed types."""
2618
+ runner = Runner('return [1, "a"] + [2, "b"] as result')
2619
+ await runner.run()
2620
+ results = runner.results
2621
+ assert len(results) == 1
2622
+ assert results[0] == {"result": [1, "a", 2, "b"]}
2623
+
2624
+ @pytest.mark.asyncio
2625
+ async def test_add_chained_three_numbers(self):
2626
+ """Test add chained three numbers."""
2627
+ runner = Runner("return 1 + 2 + 3 as result")
2628
+ await runner.run()
2629
+ results = runner.results
2630
+ assert len(results) == 1
2631
+ assert results[0] == {"result": 6}
2632
+
2633
+ @pytest.mark.asyncio
2634
+ async def test_add_chained_multiple_numbers(self):
2635
+ """Test add chained multiple numbers."""
2636
+ runner = Runner("return 10 + 20 + 30 + 40 as result")
2637
+ await runner.run()
2638
+ results = runner.results
2639
+ assert len(results) == 1
2640
+ assert results[0] == {"result": 100}
2641
+
2642
+ @pytest.mark.asyncio
2643
+ async def test_add_large_numbers(self):
2644
+ """Test add large numbers."""
2645
+ runner = Runner("return 1000000 + 2000000 as result")
2646
+ await runner.run()
2647
+ results = runner.results
2648
+ assert len(results) == 1
2649
+ assert results[0] == {"result": 3000000}
2650
+
2651
+ @pytest.mark.asyncio
2652
+ async def test_add_with_unwind(self):
2653
+ """Test add with unwind."""
2654
+ runner = Runner("unwind [1, 2, 3] as x return x + 10 as result")
2655
+ await runner.run()
2656
+ results = runner.results
2657
+ assert len(results) == 3
2658
+ assert results[0] == {"result": 11}
2659
+ assert results[1] == {"result": 12}
2660
+ assert results[2] == {"result": 13}
2661
+
2662
+ @pytest.mark.asyncio
2663
+ async def test_add_with_multiple_return_expressions(self):
2664
+ """Test add with multiple return expressions."""
2665
+ runner = Runner("return 1 + 2 as sum1, 3 + 4 as sum2, 5 + 6 as sum3")
2666
+ await runner.run()
2667
+ results = runner.results
2668
+ assert len(results) == 1
2669
+ assert results[0] == {"sum1": 3, "sum2": 7, "sum3": 11}
2670
+
2671
+ @pytest.mark.asyncio
2672
+ async def test_add_mixed_with_other_operators(self):
2673
+ """Test add mixed with other operators (precedence)."""
2674
+ runner = Runner("return 2 + 3 * 4 as result")
2675
+ await runner.run()
2676
+ results = runner.results
2677
+ assert len(results) == 1
2678
+ assert results[0] == {"result": 14}
2679
+
2680
+ @pytest.mark.asyncio
2681
+ async def test_add_with_parentheses(self):
2682
+ """Test add with parentheses."""
2683
+ runner = Runner("return (2 + 3) * 4 as result")
2684
+ await runner.run()
2685
+ results = runner.results
2686
+ assert len(results) == 1
2687
+ assert results[0] == {"result": 20}
2688
+
2689
+ @pytest.mark.asyncio
2690
+ async def test_add_nested_lists(self):
2691
+ """Test add nested lists."""
2692
+ runner = Runner("return [[1, 2]] + [[3, 4]] as result")
2693
+ await runner.run()
2694
+ results = runner.results
2695
+ assert len(results) == 1
2696
+ assert results[0] == {"result": [[1, 2], [3, 4]]}
2697
+
2698
+ @pytest.mark.asyncio
2699
+ async def test_add_with_with_clause(self):
2700
+ """Test add with with clause."""
2701
+ runner = Runner("with 5 as a, 10 as b return a + b as result")
2702
+ await runner.run()
2703
+ results = runner.results
2704
+ assert len(results) == 1
2705
+ assert results[0] == {"result": 15}
2706
+
2707
+ # ============================================================
2708
+ # UNION and UNION ALL tests
2709
+ # ============================================================
2710
+
2711
+ @pytest.mark.asyncio
2712
+ async def test_union_with_simple_values(self):
2713
+ """Test UNION with simple values."""
2714
+ runner = Runner("WITH 1 AS x RETURN x UNION WITH 2 AS x RETURN x")
2715
+ await runner.run()
2716
+ results = runner.results
2717
+ assert len(results) == 2
2718
+ assert results == [{"x": 1}, {"x": 2}]
2719
+
2720
+ @pytest.mark.asyncio
2721
+ async def test_union_removes_duplicates(self):
2722
+ """Test UNION removes duplicates."""
2723
+ runner = Runner("WITH 1 AS x RETURN x UNION WITH 1 AS x RETURN x")
2724
+ await runner.run()
2725
+ results = runner.results
2726
+ assert len(results) == 1
2727
+ assert results == [{"x": 1}]
2728
+
2729
+ @pytest.mark.asyncio
2730
+ async def test_union_all_keeps_duplicates(self):
2731
+ """Test UNION ALL keeps duplicates."""
2732
+ runner = Runner("WITH 1 AS x RETURN x UNION ALL WITH 1 AS x RETURN x")
2733
+ await runner.run()
2734
+ results = runner.results
2735
+ assert len(results) == 2
2736
+ assert results == [{"x": 1}, {"x": 1}]
2737
+
2738
+ @pytest.mark.asyncio
2739
+ async def test_union_with_multiple_columns(self):
2740
+ """Test UNION with multiple columns."""
2741
+ runner = Runner(
2742
+ "WITH 1 AS a, 'hello' AS b RETURN a, b UNION WITH 2 AS a, 'world' AS b RETURN a, b"
2743
+ )
2744
+ await runner.run()
2745
+ results = runner.results
2746
+ assert len(results) == 2
2747
+ assert results == [
2748
+ {"a": 1, "b": "hello"},
2749
+ {"a": 2, "b": "world"},
2750
+ ]
2751
+
2752
+ @pytest.mark.asyncio
2753
+ async def test_union_all_with_multiple_columns(self):
2754
+ """Test chained UNION ALL with three branches."""
2755
+ runner = Runner(
2756
+ "WITH 1 AS a RETURN a UNION ALL WITH 2 AS a RETURN a UNION ALL WITH 3 AS a RETURN a"
2757
+ )
2758
+ await runner.run()
2759
+ results = runner.results
2760
+ assert len(results) == 3
2761
+ assert results == [{"a": 1}, {"a": 2}, {"a": 3}]
2762
+
2763
+ @pytest.mark.asyncio
2764
+ async def test_chained_union_removes_duplicates(self):
2765
+ """Test chained UNION removes duplicates across all branches."""
2766
+ runner = Runner(
2767
+ "WITH 1 AS x RETURN x UNION WITH 2 AS x RETURN x UNION WITH 1 AS x RETURN x"
2768
+ )
2769
+ await runner.run()
2770
+ results = runner.results
2771
+ assert len(results) == 2
2772
+ assert results == [{"x": 1}, {"x": 2}]
2773
+
2774
+ @pytest.mark.asyncio
2775
+ async def test_union_with_unwind(self):
2776
+ """Test UNION with UNWIND."""
2777
+ runner = Runner(
2778
+ "UNWIND [1, 2] AS x RETURN x UNION UNWIND [3, 4] AS x RETURN x"
2779
+ )
2780
+ await runner.run()
2781
+ results = runner.results
2782
+ assert len(results) == 4
2783
+ assert results == [{"x": 1}, {"x": 2}, {"x": 3}, {"x": 4}]
2784
+
2785
+ @pytest.mark.asyncio
2786
+ async def test_union_with_mismatched_columns(self):
2787
+ """Test UNION with mismatched columns throws error."""
2788
+ runner = Runner("WITH 1 AS x RETURN x UNION WITH 2 AS y RETURN y")
2789
+ with pytest.raises(ValueError, match="All sub queries in a UNION must have the same return column names"):
2790
+ await runner.run()
2791
+
2792
+ @pytest.mark.asyncio
2793
+ async def test_union_with_empty_left_side(self):
2794
+ """Test UNION with empty left side."""
2795
+ runner = Runner(
2796
+ "UNWIND [] AS x RETURN x UNION WITH 1 AS x RETURN x"
2797
+ )
2798
+ await runner.run()
2799
+ results = runner.results
2800
+ assert len(results) == 1
2801
+ assert results == [{"x": 1}]
2802
+
2803
+ @pytest.mark.asyncio
2804
+ async def test_union_with_empty_right_side(self):
2805
+ """Test UNION with empty right side."""
2806
+ runner = Runner(
2807
+ "WITH 1 AS x RETURN x UNION UNWIND [] AS x RETURN x"
2808
+ )
2809
+ await runner.run()
2810
+ results = runner.results
2811
+ assert len(results) == 1
2812
+ assert results == [{"x": 1}]
@@ -1090,3 +1090,85 @@ class TestParser:
1090
1090
  "----- Number (3)"
1091
1091
  )
1092
1092
  assert ast.print() == expected
1093
+
1094
+ def test_optional_match_operation(self):
1095
+ """Test optional match operation."""
1096
+ parser = Parser()
1097
+ ast = parser.parse("OPTIONAL MATCH (n:Person) RETURN n")
1098
+ expected = (
1099
+ "ASTNode\n"
1100
+ "- OptionalMatch\n"
1101
+ "- Return\n"
1102
+ "-- Expression (n)\n"
1103
+ "--- Reference (n)"
1104
+ )
1105
+ assert ast.print() == expected
1106
+ match = ast.first_child()
1107
+ assert isinstance(match, Match)
1108
+ assert match.optional is True
1109
+ assert match.patterns[0].start_node is not None
1110
+ assert match.patterns[0].start_node.label == "Person"
1111
+ assert match.patterns[0].start_node.identifier == "n"
1112
+
1113
+ def test_optional_match_with_relationships(self):
1114
+ """Test optional match with graph pattern including relationships."""
1115
+ parser = Parser()
1116
+ ast = parser.parse("OPTIONAL MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b")
1117
+ expected = (
1118
+ "ASTNode\n"
1119
+ "- OptionalMatch\n"
1120
+ "- Return\n"
1121
+ "-- Expression (a)\n"
1122
+ "--- Reference (a)\n"
1123
+ "-- Expression (b)\n"
1124
+ "--- Reference (b)"
1125
+ )
1126
+ assert ast.print() == expected
1127
+ match = ast.first_child()
1128
+ assert isinstance(match, Match)
1129
+ assert match.optional is True
1130
+ assert len(match.patterns[0].chain) == 3
1131
+ source = match.patterns[0].chain[0]
1132
+ relationship = match.patterns[0].chain[1]
1133
+ target = match.patterns[0].chain[2]
1134
+ assert source.identifier == "a"
1135
+ assert source.label == "Person"
1136
+ assert relationship.type == "KNOWS"
1137
+ assert target.identifier == "b"
1138
+ assert target.label == "Person"
1139
+
1140
+ def test_match_followed_by_optional_match(self):
1141
+ """Test match followed by optional match."""
1142
+ parser = Parser()
1143
+ ast = parser.parse("MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b:Person) RETURN a, b")
1144
+ expected = (
1145
+ "ASTNode\n"
1146
+ "- Match\n"
1147
+ "- OptionalMatch\n"
1148
+ "- Return\n"
1149
+ "-- Expression (a)\n"
1150
+ "--- Reference (a)\n"
1151
+ "-- Expression (b)\n"
1152
+ "--- Reference (b)"
1153
+ )
1154
+ assert ast.print() == expected
1155
+ match = ast.first_child()
1156
+ assert isinstance(match, Match)
1157
+ assert match.optional is False
1158
+ optional_match = match.next
1159
+ assert isinstance(optional_match, Match)
1160
+ assert optional_match.optional is True
1161
+
1162
+ def test_regular_match_is_not_optional(self):
1163
+ """Test that regular match is not optional."""
1164
+ parser = Parser()
1165
+ ast = parser.parse("MATCH (n:Person) RETURN n")
1166
+ match = ast.first_child()
1167
+ assert isinstance(match, Match)
1168
+ assert match.optional is False
1169
+
1170
+ def test_optional_without_match_throws_error(self):
1171
+ """Test that OPTIONAL without MATCH throws error."""
1172
+ parser = Parser()
1173
+ with pytest.raises(Exception, match="Expected MATCH after OPTIONAL"):
1174
+ parser.parse("OPTIONAL RETURN 1")