flowquery 1.0.26 → 1.0.28

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 (62) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/graph/relationship.d.ts.map +1 -1
  3. package/dist/graph/relationship.js +5 -1
  4. package/dist/graph/relationship.js.map +1 -1
  5. package/dist/parsing/base_parser.d.ts +1 -1
  6. package/dist/parsing/base_parser.d.ts.map +1 -1
  7. package/dist/parsing/base_parser.js.map +1 -1
  8. package/dist/parsing/expressions/operator.d.ts +37 -1
  9. package/dist/parsing/expressions/operator.d.ts.map +1 -1
  10. package/dist/parsing/expressions/operator.js +121 -2
  11. package/dist/parsing/expressions/operator.js.map +1 -1
  12. package/dist/parsing/expressions/reference.d.ts +1 -0
  13. package/dist/parsing/expressions/reference.d.ts.map +1 -1
  14. package/dist/parsing/expressions/reference.js +3 -0
  15. package/dist/parsing/expressions/reference.js.map +1 -1
  16. package/dist/parsing/functions/function_factory.d.ts +1 -0
  17. package/dist/parsing/functions/function_factory.d.ts.map +1 -1
  18. package/dist/parsing/functions/function_factory.js +1 -0
  19. package/dist/parsing/functions/function_factory.js.map +1 -1
  20. package/dist/parsing/functions/string_distance.d.ts +7 -0
  21. package/dist/parsing/functions/string_distance.d.ts.map +1 -0
  22. package/dist/parsing/functions/string_distance.js +84 -0
  23. package/dist/parsing/functions/string_distance.js.map +1 -0
  24. package/dist/parsing/parser.d.ts +6 -0
  25. package/dist/parsing/parser.d.ts.map +1 -1
  26. package/dist/parsing/parser.js +127 -15
  27. package/dist/parsing/parser.js.map +1 -1
  28. package/dist/tokenization/keyword.d.ts +4 -1
  29. package/dist/tokenization/keyword.d.ts.map +1 -1
  30. package/dist/tokenization/keyword.js +3 -0
  31. package/dist/tokenization/keyword.js.map +1 -1
  32. package/dist/tokenization/token.d.ts +6 -0
  33. package/dist/tokenization/token.d.ts.map +1 -1
  34. package/dist/tokenization/token.js +18 -0
  35. package/dist/tokenization/token.js.map +1 -1
  36. package/docs/flowquery.min.js +1 -1
  37. package/flowquery-py/pyproject.toml +1 -1
  38. package/flowquery-py/src/graph/relationship.py +5 -1
  39. package/flowquery-py/src/parsing/expressions/__init__.py +4 -0
  40. package/flowquery-py/src/parsing/expressions/operator.py +102 -0
  41. package/flowquery-py/src/parsing/functions/__init__.py +2 -0
  42. package/flowquery-py/src/parsing/functions/string_distance.py +88 -0
  43. package/flowquery-py/src/parsing/parser.py +120 -10
  44. package/flowquery-py/src/tokenization/keyword.py +3 -0
  45. package/flowquery-py/src/tokenization/token.py +21 -0
  46. package/flowquery-py/tests/compute/test_runner.py +406 -1
  47. package/flowquery-py/tests/parsing/test_expression.py +121 -1
  48. package/flowquery-py/tests/parsing/test_parser.py +203 -0
  49. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  50. package/package.json +1 -1
  51. package/src/graph/relationship.ts +4 -1
  52. package/src/parsing/base_parser.ts +1 -1
  53. package/src/parsing/expressions/operator.ts +129 -1
  54. package/src/parsing/expressions/reference.ts +8 -5
  55. package/src/parsing/functions/function_factory.ts +1 -0
  56. package/src/parsing/functions/string_distance.ts +80 -0
  57. package/src/parsing/parser.ts +138 -14
  58. package/src/tokenization/keyword.ts +3 -0
  59. package/src/tokenization/token.ts +24 -0
  60. package/tests/compute/runner.test.ts +379 -0
  61. package/tests/parsing/expression.test.ts +150 -16
  62. package/tests/parsing/parser.test.ts +200 -0
@@ -418,6 +418,42 @@ class TestRunner:
418
418
  assert len(results) == 1
419
419
  assert results[0] == {"replace": "hexxo"}
420
420
 
421
+ @pytest.mark.asyncio
422
+ async def test_string_distance_function(self):
423
+ """Test string_distance function."""
424
+ runner = Runner('RETURN string_distance("kitten", "sitting") as dist')
425
+ await runner.run()
426
+ results = runner.results
427
+ assert len(results) == 1
428
+ assert results[0]["dist"] == pytest.approx(3 / 7)
429
+
430
+ @pytest.mark.asyncio
431
+ async def test_string_distance_identical_strings(self):
432
+ """Test string_distance function with identical strings."""
433
+ runner = Runner('RETURN string_distance("hello", "hello") as dist')
434
+ await runner.run()
435
+ results = runner.results
436
+ assert len(results) == 1
437
+ assert results[0] == {"dist": 0}
438
+
439
+ @pytest.mark.asyncio
440
+ async def test_string_distance_empty_string(self):
441
+ """Test string_distance function with empty string."""
442
+ runner = Runner('RETURN string_distance("", "abc") as dist')
443
+ await runner.run()
444
+ results = runner.results
445
+ assert len(results) == 1
446
+ assert results[0] == {"dist": 1}
447
+
448
+ @pytest.mark.asyncio
449
+ async def test_string_distance_both_empty(self):
450
+ """Test string_distance function with both empty strings."""
451
+ runner = Runner('RETURN string_distance("", "") as dist')
452
+ await runner.run()
453
+ results = runner.results
454
+ assert len(results) == 1
455
+ assert results[0] == {"dist": 0}
456
+
421
457
  @pytest.mark.asyncio
422
458
  async def test_f_string_with_escaped_braces(self):
423
459
  """Test f-string with escaped braces."""
@@ -1138,6 +1174,99 @@ class TestRunner:
1138
1174
  with pytest.raises(ValueError, match="Circular relationship detected"):
1139
1175
  await match.run()
1140
1176
 
1177
+ @pytest.mark.asyncio
1178
+ async def test_multi_hop_match_with_min_hops_constraint_1(self):
1179
+ """Test multi-hop match with min hops constraint *1.."""
1180
+ await Runner(
1181
+ """
1182
+ CREATE VIRTUAL (:MinHop1Person) AS {
1183
+ unwind [
1184
+ {id: 1, name: 'Person 1'},
1185
+ {id: 2, name: 'Person 2'},
1186
+ {id: 3, name: 'Person 3'},
1187
+ {id: 4, name: 'Person 4'}
1188
+ ] as record
1189
+ RETURN record.id as id, record.name as name
1190
+ }
1191
+ """
1192
+ ).run()
1193
+ await Runner(
1194
+ """
1195
+ CREATE VIRTUAL (:MinHop1Person)-[:KNOWS]-(:MinHop1Person) AS {
1196
+ unwind [
1197
+ {left_id: 1, right_id: 2},
1198
+ {left_id: 2, right_id: 3},
1199
+ {left_id: 3, right_id: 4}
1200
+ ] as record
1201
+ RETURN record.left_id as left_id, record.right_id as right_id
1202
+ }
1203
+ """
1204
+ ).run()
1205
+ match = Runner(
1206
+ """
1207
+ MATCH (a:MinHop1Person)-[:KNOWS*1..]->(b:MinHop1Person)
1208
+ RETURN a.name AS name1, b.name AS name2
1209
+ """
1210
+ )
1211
+ await match.run()
1212
+ results = match.results
1213
+ # *1.. means at least 1 hop, so no zero-hop (self) matches
1214
+ # Person 1: 1-hop to P2, 2-hop to P3, 3-hop to P4
1215
+ # Person 2: 1-hop to P3, 2-hop to P4
1216
+ # Person 3: 1-hop to P4
1217
+ # Person 4: no outgoing edges
1218
+ assert len(results) == 6
1219
+ assert results[0] == {"name1": "Person 1", "name2": "Person 2"}
1220
+ assert results[1] == {"name1": "Person 1", "name2": "Person 3"}
1221
+ assert results[2] == {"name1": "Person 1", "name2": "Person 4"}
1222
+ assert results[3] == {"name1": "Person 2", "name2": "Person 3"}
1223
+ assert results[4] == {"name1": "Person 2", "name2": "Person 4"}
1224
+ assert results[5] == {"name1": "Person 3", "name2": "Person 4"}
1225
+
1226
+ @pytest.mark.asyncio
1227
+ async def test_multi_hop_match_with_min_hops_constraint_2(self):
1228
+ """Test multi-hop match with min hops constraint *2.."""
1229
+ await Runner(
1230
+ """
1231
+ CREATE VIRTUAL (:MinHop2Person) AS {
1232
+ unwind [
1233
+ {id: 1, name: 'Person 1'},
1234
+ {id: 2, name: 'Person 2'},
1235
+ {id: 3, name: 'Person 3'},
1236
+ {id: 4, name: 'Person 4'}
1237
+ ] as record
1238
+ RETURN record.id as id, record.name as name
1239
+ }
1240
+ """
1241
+ ).run()
1242
+ await Runner(
1243
+ """
1244
+ CREATE VIRTUAL (:MinHop2Person)-[:KNOWS]-(:MinHop2Person) AS {
1245
+ unwind [
1246
+ {left_id: 1, right_id: 2},
1247
+ {left_id: 2, right_id: 3},
1248
+ {left_id: 3, right_id: 4}
1249
+ ] as record
1250
+ RETURN record.left_id as left_id, record.right_id as right_id
1251
+ }
1252
+ """
1253
+ ).run()
1254
+ match = Runner(
1255
+ """
1256
+ MATCH (a:MinHop2Person)-[:KNOWS*2..]->(b:MinHop2Person)
1257
+ RETURN a.name AS name1, b.name AS name2
1258
+ """
1259
+ )
1260
+ await match.run()
1261
+ results = match.results
1262
+ # *2.. means at least 2 hops
1263
+ # Person 1: 2-hop to P3, 3-hop to P4
1264
+ # Person 2: 2-hop to P4
1265
+ assert len(results) == 3
1266
+ assert results[0] == {"name1": "Person 1", "name2": "Person 3"}
1267
+ assert results[1] == {"name1": "Person 1", "name2": "Person 4"}
1268
+ assert results[2] == {"name1": "Person 2", "name2": "Person 4"}
1269
+
1141
1270
  @pytest.mark.asyncio
1142
1271
  async def test_multi_hop_match_with_variable_length_relationships(self):
1143
1272
  """Test multi-hop match with variable length relationships."""
@@ -1669,4 +1798,280 @@ class TestRunner:
1669
1798
  await runner.run()
1670
1799
  results = runner.results
1671
1800
  assert len(results) == 1
1672
- assert results[0] == {"name1": "Node 1", "name2": "Node 2"}
1801
+ assert results[0] == {"name1": "Node 1", "name2": "Node 2"}
1802
+
1803
+ @pytest.mark.asyncio
1804
+ async def test_match_with_node_reference_passed_through_with(self):
1805
+ """Test that node variables passed through WITH can be re-referenced in subsequent MATCH."""
1806
+ await Runner("""
1807
+ CREATE VIRTUAL (:WithRefUser) AS {
1808
+ UNWIND [
1809
+ {id: 1, name: 'Alice', mail: 'alice@test.com', jobTitle: 'CEO'},
1810
+ {id: 2, name: 'Bob', mail: 'bob@test.com', jobTitle: 'VP'},
1811
+ {id: 3, name: 'Carol', mail: 'carol@test.com', jobTitle: 'VP'},
1812
+ {id: 4, name: 'Dave', mail: 'dave@test.com', jobTitle: 'Engineer'}
1813
+ ] AS record
1814
+ RETURN record.id AS id, record.name AS name, record.mail AS mail, record.jobTitle AS jobTitle
1815
+ }
1816
+ """).run()
1817
+ await Runner("""
1818
+ CREATE VIRTUAL (:WithRefUser)-[:MANAGES]-(:WithRefUser) AS {
1819
+ UNWIND [
1820
+ {left_id: 1, right_id: 2},
1821
+ {left_id: 1, right_id: 3},
1822
+ {left_id: 2, right_id: 4}
1823
+ ] AS record
1824
+ RETURN record.left_id AS left_id, record.right_id AS right_id
1825
+ }
1826
+ """).run()
1827
+ runner = Runner("""
1828
+ MATCH (ceo:WithRefUser)-[:MANAGES]->(dr1:WithRefUser)
1829
+ WHERE ceo.jobTitle = 'CEO'
1830
+ WITH ceo, dr1
1831
+ MATCH (ceo)-[:MANAGES]->(dr2:WithRefUser)
1832
+ WHERE dr1.mail <> dr2.mail
1833
+ RETURN ceo.name AS ceo, dr1.name AS dr1, dr2.name AS dr2
1834
+ """)
1835
+ await runner.run()
1836
+ results = runner.results
1837
+ # CEO (Alice) manages Bob and Carol. All distinct pairs:
1838
+ # (Alice, Bob, Carol) and (Alice, Carol, Bob)
1839
+ assert len(results) == 2
1840
+ assert results[0] == {"ceo": "Alice", "dr1": "Bob", "dr2": "Carol"}
1841
+ assert results[1] == {"ceo": "Alice", "dr1": "Carol", "dr2": "Bob"}
1842
+
1843
+ async def test_match_with_node_reference_reuse_with_label(self):
1844
+ """Test that reusing a node variable with a label creates a NodeReference, not a new node."""
1845
+ await Runner("""
1846
+ CREATE VIRTUAL (:RefLabelUser) AS {
1847
+ UNWIND [
1848
+ {id: 1, name: 'Alice', jobTitle: 'CEO'},
1849
+ {id: 2, name: 'Bob', jobTitle: 'VP'},
1850
+ {id: 3, name: 'Carol', jobTitle: 'VP'},
1851
+ {id: 4, name: 'Dave', jobTitle: 'Engineer'}
1852
+ ] AS record
1853
+ RETURN record.id AS id, record.name AS name, record.jobTitle AS jobTitle
1854
+ }
1855
+ """).run()
1856
+ await Runner("""
1857
+ CREATE VIRTUAL (:RefLabelUser)-[:MANAGES]-(:RefLabelUser) AS {
1858
+ UNWIND [
1859
+ {left_id: 1, right_id: 2},
1860
+ {left_id: 1, right_id: 3},
1861
+ {left_id: 2, right_id: 4}
1862
+ ] AS record
1863
+ RETURN record.left_id AS left_id, record.right_id AS right_id
1864
+ }
1865
+ """).run()
1866
+ # Uses (ceo:RefLabelUser) with label in both MATCH clauses.
1867
+ # Previously this would create a new node instead of a NodeReference.
1868
+ runner = Runner("""
1869
+ MATCH (ceo:RefLabelUser)-[:MANAGES]->(dr1:RefLabelUser)
1870
+ WHERE ceo.jobTitle = 'CEO'
1871
+ WITH ceo, dr1
1872
+ MATCH (ceo:RefLabelUser)-[:MANAGES]->(dr2:RefLabelUser)
1873
+ WHERE dr1.name <> dr2.name
1874
+ RETURN ceo.name AS ceo, dr1.name AS dr1, dr2.name AS dr2
1875
+ """)
1876
+ await runner.run()
1877
+ results = runner.results
1878
+ assert len(results) == 2
1879
+ assert results[0] == {"ceo": "Alice", "dr1": "Bob", "dr2": "Carol"}
1880
+ assert results[1] == {"ceo": "Alice", "dr1": "Carol", "dr2": "Bob"}
1881
+
1882
+ @pytest.mark.asyncio
1883
+ async def test_where_with_is_null(self):
1884
+ """Test WHERE with IS NULL."""
1885
+ runner = Runner("""
1886
+ unwind [{name: 'Alice', age: 30}, {name: 'Bob', age: null}] as person
1887
+ with person.name as name, person.age as age
1888
+ where age IS NULL
1889
+ return name
1890
+ """)
1891
+ await runner.run()
1892
+ results = runner.results
1893
+ assert len(results) == 1
1894
+ assert results[0] == {"name": "Bob"}
1895
+
1896
+ @pytest.mark.asyncio
1897
+ async def test_where_with_is_not_null(self):
1898
+ """Test WHERE with IS NOT NULL."""
1899
+ runner = Runner("""
1900
+ unwind [{name: 'Alice', age: 30}, {name: 'Bob', age: null}] as person
1901
+ with person.name as name, person.age as age
1902
+ where age IS NOT NULL
1903
+ return name, age
1904
+ """)
1905
+ await runner.run()
1906
+ results = runner.results
1907
+ assert len(results) == 1
1908
+ assert results[0] == {"name": "Alice", "age": 30}
1909
+
1910
+ @pytest.mark.asyncio
1911
+ async def test_where_with_is_not_null_multiple_results(self):
1912
+ """Test WHERE with IS NOT NULL filters multiple results."""
1913
+ runner = Runner("""
1914
+ unwind [{name: 'Alice', age: 30}, {name: 'Bob', age: null}, {name: 'Carol', age: 25}] as person
1915
+ with person.name as name, person.age as age
1916
+ where age IS NOT NULL
1917
+ return name, age
1918
+ """)
1919
+ await runner.run()
1920
+ results = runner.results
1921
+ assert len(results) == 2
1922
+ assert results[0] == {"name": "Alice", "age": 30}
1923
+ assert results[1] == {"name": "Carol", "age": 25}
1924
+
1925
+ @pytest.mark.asyncio
1926
+ async def test_where_with_in_list_check(self):
1927
+ """Test WHERE with IN list check."""
1928
+ runner = Runner("""
1929
+ unwind range(1, 10) as n
1930
+ with n
1931
+ where n IN [2, 4, 6, 8]
1932
+ return n
1933
+ """)
1934
+ await runner.run()
1935
+ results = runner.results
1936
+ assert len(results) == 4
1937
+ assert [r["n"] for r in results] == [2, 4, 6, 8]
1938
+
1939
+ @pytest.mark.asyncio
1940
+ async def test_where_with_not_in_list_check(self):
1941
+ """Test WHERE with NOT IN list check."""
1942
+ runner = Runner("""
1943
+ unwind range(1, 5) as n
1944
+ with n
1945
+ where n NOT IN [2, 4]
1946
+ return n
1947
+ """)
1948
+ await runner.run()
1949
+ results = runner.results
1950
+ assert len(results) == 3
1951
+ assert [r["n"] for r in results] == [1, 3, 5]
1952
+
1953
+ @pytest.mark.asyncio
1954
+ async def test_where_with_in_string_list(self):
1955
+ """Test WHERE with IN string list."""
1956
+ runner = Runner("""
1957
+ unwind ['apple', 'banana', 'cherry', 'date'] as fruit
1958
+ with fruit
1959
+ where fruit IN ['banana', 'date']
1960
+ return fruit
1961
+ """)
1962
+ await runner.run()
1963
+ results = runner.results
1964
+ assert len(results) == 2
1965
+ assert [r["fruit"] for r in results] == ["banana", "date"]
1966
+
1967
+ @pytest.mark.asyncio
1968
+ async def test_where_with_in_combined_with_and(self):
1969
+ """Test WHERE with IN combined with AND."""
1970
+ runner = Runner("""
1971
+ unwind range(1, 20) as n
1972
+ with n
1973
+ where n IN [1, 5, 10, 15, 20] AND n > 5
1974
+ return n
1975
+ """)
1976
+ await runner.run()
1977
+ results = runner.results
1978
+ assert len(results) == 3
1979
+ assert [r["n"] for r in results] == [10, 15, 20]
1980
+
1981
+ @pytest.mark.asyncio
1982
+ async def test_where_with_contains(self):
1983
+ """Test WHERE with CONTAINS."""
1984
+ runner = Runner("""
1985
+ unwind ['apple', 'banana', 'grape', 'pineapple'] as fruit
1986
+ with fruit
1987
+ where fruit CONTAINS 'apple'
1988
+ return fruit
1989
+ """)
1990
+ await runner.run()
1991
+ results = runner.results
1992
+ assert len(results) == 2
1993
+ assert [r["fruit"] for r in results] == ["apple", "pineapple"]
1994
+
1995
+ @pytest.mark.asyncio
1996
+ async def test_where_with_not_contains(self):
1997
+ """Test WHERE with NOT CONTAINS."""
1998
+ runner = Runner("""
1999
+ unwind ['apple', 'banana', 'grape', 'pineapple'] as fruit
2000
+ with fruit
2001
+ where fruit NOT CONTAINS 'apple'
2002
+ return fruit
2003
+ """)
2004
+ await runner.run()
2005
+ results = runner.results
2006
+ assert len(results) == 2
2007
+ assert [r["fruit"] for r in results] == ["banana", "grape"]
2008
+
2009
+ @pytest.mark.asyncio
2010
+ async def test_where_with_starts_with(self):
2011
+ """Test WHERE with STARTS WITH."""
2012
+ runner = Runner("""
2013
+ unwind ['apple', 'apricot', 'banana', 'avocado'] as fruit
2014
+ with fruit
2015
+ where fruit STARTS WITH 'ap'
2016
+ return fruit
2017
+ """)
2018
+ await runner.run()
2019
+ results = runner.results
2020
+ assert len(results) == 2
2021
+ assert [r["fruit"] for r in results] == ["apple", "apricot"]
2022
+
2023
+ @pytest.mark.asyncio
2024
+ async def test_where_with_not_starts_with(self):
2025
+ """Test WHERE with NOT STARTS WITH."""
2026
+ runner = Runner("""
2027
+ unwind ['apple', 'apricot', 'banana', 'avocado'] as fruit
2028
+ with fruit
2029
+ where fruit NOT STARTS WITH 'ap'
2030
+ return fruit
2031
+ """)
2032
+ await runner.run()
2033
+ results = runner.results
2034
+ assert len(results) == 2
2035
+ assert [r["fruit"] for r in results] == ["banana", "avocado"]
2036
+
2037
+ @pytest.mark.asyncio
2038
+ async def test_where_with_ends_with(self):
2039
+ """Test WHERE with ENDS WITH."""
2040
+ runner = Runner("""
2041
+ unwind ['apple', 'pineapple', 'banana', 'grape'] as fruit
2042
+ with fruit
2043
+ where fruit ENDS WITH 'ple'
2044
+ return fruit
2045
+ """)
2046
+ await runner.run()
2047
+ results = runner.results
2048
+ assert len(results) == 2
2049
+ assert [r["fruit"] for r in results] == ["apple", "pineapple"]
2050
+
2051
+ @pytest.mark.asyncio
2052
+ async def test_where_with_not_ends_with(self):
2053
+ """Test WHERE with NOT ENDS WITH."""
2054
+ runner = Runner("""
2055
+ unwind ['apple', 'pineapple', 'banana', 'grape'] as fruit
2056
+ with fruit
2057
+ where fruit NOT ENDS WITH 'ple'
2058
+ return fruit
2059
+ """)
2060
+ await runner.run()
2061
+ results = runner.results
2062
+ assert len(results) == 2
2063
+ assert [r["fruit"] for r in results] == ["banana", "grape"]
2064
+
2065
+ @pytest.mark.asyncio
2066
+ async def test_where_with_contains_combined_with_and(self):
2067
+ """Test WHERE with CONTAINS combined with AND."""
2068
+ runner = Runner("""
2069
+ unwind ['apple', 'pineapple', 'applesauce', 'banana'] as fruit
2070
+ with fruit
2071
+ where fruit CONTAINS 'apple' AND fruit STARTS WITH 'pine'
2072
+ return fruit
2073
+ """)
2074
+ await runner.run()
2075
+ results = runner.results
2076
+ assert len(results) == 1
2077
+ assert results[0]["fruit"] == "pineapple"
@@ -3,9 +3,12 @@
3
3
  import pytest
4
4
  from flowquery.parsing.expressions.expression import Expression
5
5
  from flowquery.parsing.expressions.operator import (
6
- Add, Subtract, Multiply, Power, GreaterThan, And
6
+ Add, Subtract, Multiply, Power, GreaterThan, And, Is, IsNot,
7
+ Contains, NotContains, StartsWith, NotStartsWith, EndsWith, NotEndsWith,
7
8
  )
8
9
  from flowquery.parsing.expressions.number import Number
10
+ from flowquery.parsing.expressions.string import String
11
+ from flowquery.parsing.components.null import Null
9
12
 
10
13
 
11
14
  class TestExpression:
@@ -47,3 +50,120 @@ class TestExpression:
47
50
  expression.add_node(Number("1"))
48
51
  expression.finish()
49
52
  assert expression.value() == 1
53
+
54
+ def test_is_null_with_null_value(self):
55
+ """Test IS NULL with null value."""
56
+ expression = Expression()
57
+ expression.add_node(Null())
58
+ expression.add_node(Is())
59
+ expression.add_node(Null())
60
+ expression.finish()
61
+ assert expression.value() == 1
62
+
63
+ def test_is_null_with_non_null_value(self):
64
+ """Test IS NULL with non-null value."""
65
+ expression = Expression()
66
+ expression.add_node(Number("42"))
67
+ expression.add_node(Is())
68
+ expression.add_node(Null())
69
+ expression.finish()
70
+ assert expression.value() == 0
71
+
72
+ def test_is_not_null_with_non_null_value(self):
73
+ """Test IS NOT NULL with non-null value."""
74
+ expression = Expression()
75
+ expression.add_node(Number("42"))
76
+ expression.add_node(IsNot())
77
+ expression.add_node(Null())
78
+ expression.finish()
79
+ assert expression.value() == 1
80
+
81
+ def test_is_not_null_with_null_value(self):
82
+ """Test IS NOT NULL with null value."""
83
+ expression = Expression()
84
+ expression.add_node(Null())
85
+ expression.add_node(IsNot())
86
+ expression.add_node(Null())
87
+ expression.finish()
88
+ assert expression.value() == 0
89
+
90
+ def test_contains_with_matching_substring(self):
91
+ """Test CONTAINS with matching substring."""
92
+ expression = Expression()
93
+ expression.add_node(String("pineapple"))
94
+ expression.add_node(Contains())
95
+ expression.add_node(String("apple"))
96
+ expression.finish()
97
+ assert expression.value() == 1
98
+
99
+ def test_contains_with_non_matching_substring(self):
100
+ """Test CONTAINS with non-matching substring."""
101
+ expression = Expression()
102
+ expression.add_node(String("banana"))
103
+ expression.add_node(Contains())
104
+ expression.add_node(String("apple"))
105
+ expression.finish()
106
+ assert expression.value() == 0
107
+
108
+ def test_not_contains(self):
109
+ """Test NOT CONTAINS."""
110
+ expression = Expression()
111
+ expression.add_node(String("banana"))
112
+ expression.add_node(NotContains())
113
+ expression.add_node(String("apple"))
114
+ expression.finish()
115
+ assert expression.value() == 1
116
+
117
+ def test_starts_with_matching_prefix(self):
118
+ """Test STARTS WITH matching prefix."""
119
+ expression = Expression()
120
+ expression.add_node(String("pineapple"))
121
+ expression.add_node(StartsWith())
122
+ expression.add_node(String("pine"))
123
+ expression.finish()
124
+ assert expression.value() == 1
125
+
126
+ def test_starts_with_non_matching_prefix(self):
127
+ """Test STARTS WITH non-matching prefix."""
128
+ expression = Expression()
129
+ expression.add_node(String("pineapple"))
130
+ expression.add_node(StartsWith())
131
+ expression.add_node(String("apple"))
132
+ expression.finish()
133
+ assert expression.value() == 0
134
+
135
+ def test_not_starts_with(self):
136
+ """Test NOT STARTS WITH."""
137
+ expression = Expression()
138
+ expression.add_node(String("pineapple"))
139
+ expression.add_node(NotStartsWith())
140
+ expression.add_node(String("apple"))
141
+ expression.finish()
142
+ assert expression.value() == 1
143
+
144
+ def test_ends_with_matching_suffix(self):
145
+ """Test ENDS WITH matching suffix."""
146
+ expression = Expression()
147
+ expression.add_node(String("pineapple"))
148
+ expression.add_node(EndsWith())
149
+ expression.add_node(String("apple"))
150
+ expression.finish()
151
+ assert expression.value() == 1
152
+
153
+ def test_ends_with_non_matching_suffix(self):
154
+ """Test ENDS WITH non-matching suffix."""
155
+ expression = Expression()
156
+ expression.add_node(String("pineapple"))
157
+ expression.add_node(EndsWith())
158
+ expression.add_node(String("banana"))
159
+ expression.finish()
160
+ assert expression.value() == 0
161
+
162
+ def test_not_ends_with(self):
163
+ """Test NOT ENDS WITH."""
164
+ expression = Expression()
165
+ expression.add_node(String("pineapple"))
166
+ expression.add_node(NotEndsWith())
167
+ expression.add_node(String("banana"))
168
+ expression.finish()
169
+ assert expression.value() == 1