Skip to content

Commit 4085163

Browse files
committed
RDBC-1027: add wrap_previous_query_clauses parameter to and_also()
C# AndAlso(wrapPreviousQueryClauses: true) wraps all preceding WHERE tokens in OpenSubclauseToken/CloseSubclauseToken before appending AND, fixing RQL operator precedence when OR-joined search clauses are combined with AND. Without wrapping, RQL AND binds tighter than OR, producing wrong results. Regression test: test_query_clause_precedence.py verifies that and_also with wrap=True produces "(a OR b) AND c" instead of "a OR b AND c".
1 parent 752c115 commit 4085163

2 files changed

Lines changed: 112 additions & 3 deletions

File tree

ravendb/documents/session/query.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -723,14 +723,18 @@ def _where_regex(self, field_name: str, pattern: str) -> None:
723723
where_token = WhereToken.create(WhereOperator.REGEX, field_name, parameter)
724724
tokens.append(where_token)
725725

726-
def _and_also(self) -> None:
726+
def _and_also(self, wrap_previous_query_clauses: bool = False) -> None:
727727
tokens = self.__get_current_where_tokens()
728728
if not tokens:
729729
return
730730

731731
if isinstance(tokens[-1], QueryOperatorToken):
732732
raise TypeError("Cannot add AND, previous token was already an operator token")
733733

734+
if wrap_previous_query_clauses:
735+
tokens.insert(0, OpenSubclauseToken.create())
736+
tokens.append(CloseSubclauseToken.create())
737+
734738
tokens.append(QueryOperatorToken.AND())
735739

736740
def _or_else(self) -> None:
@@ -2499,8 +2503,8 @@ def where_regex(self, field_name: str, pattern: str) -> DocumentQuery[_T]:
24992503
self._where_regex(field_name, pattern)
25002504
return self
25012505

2502-
def and_also(self) -> DocumentQuery[_T]:
2503-
self._and_also()
2506+
def and_also(self, wrap_previous_query_clauses: bool = False) -> DocumentQuery[_T]:
2507+
self._and_also(wrap_previous_query_clauses)
25042508
return self
25052509

25062510
def or_else(self) -> DocumentQuery[_T]:
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
DocumentQuery: and_also(wrap_previous_query_clauses=True) wraps preceding WHERE
3+
tokens in a subclause so AND has the correct precedence relative to OR.
4+
5+
C# reference: FastTests/Client/Queries/QueryTests.cs
6+
Query_CreateClausesForQueryDynamicallyWithOnBeforeQueryEvent
7+
"""
8+
9+
from ravendb.tests.test_base import TestBase
10+
11+
12+
class Article:
13+
def __init__(self, title: str = "", description: str = "", is_deleted: bool = False):
14+
self.title = title
15+
self.description = description
16+
self.is_deleted = is_deleted
17+
18+
19+
class TestRavenDBAndAlsoWrapClauses(TestBase):
20+
def setUp(self):
21+
super().setUp()
22+
with self.store.open_session() as session:
23+
session.store(Article(title="foo", description="bar", is_deleted=False), "articles/1")
24+
session.store(Article(title="foo", description="bar", is_deleted=True), "articles/2")
25+
session.save_changes()
26+
27+
def test_and_also_accepts_wrap_previous_query_clauses_parameter(self):
28+
"""
29+
C# spec: query.AndAlso(wrapPreviousQueryClauses: true) is a named parameter
30+
that wraps preceding clauses in parentheses before appending AND.
31+
and_also(wrap_previous_query_clauses=True) must be accepted without error.
32+
"""
33+
with self.store.open_session() as session:
34+
q = session.advanced.document_query(object_type=Article)
35+
q.and_also(wrap_previous_query_clauses=True)
36+
37+
def test_and_also_with_wrap_produces_subclause_rql(self):
38+
"""
39+
C# spec: QueryTests.Query_CreateClausesForQueryDynamicallyWithOnBeforeQueryEvent
40+
builds: search(Title, $p0) or search(Description, $p1)
41+
then adds: andAlso(wrapPreviousQueryClauses: true).WhereEquals(IsDeleted, true)
42+
expected RQL: "from 'Articles' where (search(Title, $p0) or search(Description, $p1)) and IsDeleted = $p2"
43+
"""
44+
with self.store.open_session() as session:
45+
q = session.advanced.document_query(object_type=Article)
46+
q = q.search("title", "foo")
47+
q = q.or_else()
48+
q = q.search("description", "bar")
49+
q = q.and_also(wrap_previous_query_clauses=True)
50+
q = q.where_equals("is_deleted", True)
51+
52+
rql = q.index_query.query
53+
self.assertIn(
54+
"(search(",
55+
rql,
56+
f"RQL should open subclause before search(), got: {rql!r}",
57+
)
58+
self.assertIn(
59+
") and ",
60+
rql,
61+
f"RQL should close subclause before AND, got: {rql!r}",
62+
)
63+
self.assertIn(
64+
"is_deleted",
65+
rql,
66+
f"RQL should contain is_deleted after AND, got: {rql!r}",
67+
)
68+
69+
def test_and_also_with_wrap_returns_one_filtered_result(self):
70+
"""
71+
C# spec: expected results: 1 document (is_deleted=true only).
72+
(search(title, foo) OR search(description, bar)) AND is_deleted=true
73+
matches only articles/2.
74+
"""
75+
with self.store.open_session() as session:
76+
q = session.advanced.document_query(object_type=Article)
77+
q = q.search("title", "foo")
78+
q = q.or_else()
79+
q = q.search("description", "bar")
80+
q = q.and_also(wrap_previous_query_clauses=True)
81+
q = q.where_equals("is_deleted", True)
82+
results = list(q)
83+
84+
self.assertEqual(
85+
1,
86+
len(results),
87+
f"(title=foo OR description=bar) AND is_deleted=true should return 1 result, got {len(results)}",
88+
)
89+
90+
def test_and_also_without_or_works(self):
91+
"""
92+
and_also() works correctly when there is no preceding OR to wrap.
93+
"""
94+
with self.store.open_session() as session:
95+
q = session.advanced.document_query(object_type=Article)
96+
q = q.where_equals("title", "foo")
97+
q = q.and_also()
98+
q = q.where_equals("is_deleted", True)
99+
results = list(q)
100+
101+
self.assertEqual(
102+
1,
103+
len(results),
104+
"Simple AND (no preceding OR) should return exactly 1 result",
105+
)

0 commit comments

Comments
 (0)