Skip to content

Commit c60cd18

Browse files
authored
Merge pull request #272 from redknightlois/RDBC-1026
RDBC-1026/1027/1028 Fix skip/take pagination, add and_also clause wrapping, fix suggest alias escaping
2 parents a69340a + c4df152 commit c60cd18

5 files changed

Lines changed: 252 additions & 6 deletions

File tree

ravendb/documents/session/query.py

Lines changed: 8 additions & 4 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:
@@ -923,7 +927,7 @@ def __build_pagination(self, query_text: List[str]) -> None:
923927
query_text.append(" limit $")
924928
query_text.append(self.__add_query_parameter(self._start or 0))
925929
query_text.append(", $")
926-
query_text.append(self.__add_query_parameter(self._page_size or 0))
930+
query_text.append(self.__add_query_parameter(self._page_size))
927931

928932
def __build_include(self, query_text: List[str]) -> None:
929933
if (
@@ -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]:

ravendb/documents/session/tokens/query_tokens/definitions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from ravendb.documents.session.tokens.query_tokens.query_token import QueryToken
2323
from ravendb.documents.session.utils.document_query import DocumentQueryHelper
2424
from ravendb.primitives.constants import VectorSearch
25-
from ravendb.tools.utils import Utils
25+
from ravendb.tools.utils import Utils, QueryFieldUtil
2626

2727

2828
class CompareExchangeValueIncludesToken(QueryToken):
@@ -992,7 +992,7 @@ def write_to(self, writer: List[str]) -> None:
992992
return
993993

994994
writer.append(" as ")
995-
writer.append(self.__alias)
995+
writer.append(QueryFieldUtil.escape_if_necessary(self.__alias))
996996

997997

998998
class VectorSearchToken(WhereToken):
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+
)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
Query pagination: skip() without take() returns the expected documents.
3+
4+
C# reference: FastTests/Issues/RavenDB_20542.cs
5+
AddLongSkipToLINQ
6+
"""
7+
8+
from ravendb.tests.test_base import TestBase
9+
10+
11+
class UserSkip:
12+
def __init__(self, name: str = ""):
13+
self.name = name
14+
15+
16+
class TestRavenDB20542(TestBase):
17+
def setUp(self):
18+
super().setUp()
19+
with self.store.open_session() as session:
20+
for name in ["AA", "BB", "CC"]:
21+
session.store(UserSkip(name=name))
22+
session.save_changes()
23+
24+
def test_skip_one_without_take_returns_remaining_documents(self):
25+
"""
26+
C# spec: session.Query<User>().Skip(1).ToList() → 2 results (AA, BB, CC minus 1).
27+
"""
28+
with self.store.open_session() as session:
29+
q = session.advanced.document_query(object_type=UserSkip)
30+
q = q.skip(1)
31+
results = list(q)
32+
33+
self.assertEqual(
34+
2,
35+
len(results),
36+
f"skip(1) on 3 documents should return 2, but got {len(results)}",
37+
)
38+
39+
def test_skip_with_max_long_returns_no_results(self):
40+
"""
41+
C# spec: session.Query<User>().Skip(long.MaxValue).ToList() → 0 results.
42+
Skipping past all documents returns an empty list.
43+
"""
44+
with self.store.open_session() as session:
45+
q = session.advanced.document_query(object_type=UserSkip)
46+
q = q.skip(9223372036854775807)
47+
results = list(q)
48+
49+
self.assertEqual(
50+
0,
51+
len(results),
52+
f"skip(long.MaxValue) should skip all documents and return 0, got {len(results)}",
53+
)
54+
55+
# ------------------------------------------------------------------ #
56+
# Baseline: skip() combined with take() #
57+
# ------------------------------------------------------------------ #
58+
59+
def test_skip_with_take_returns_correct_slice(self):
60+
"""
61+
Baseline: skip(1).take(10) on 3 documents returns 2.
62+
"""
63+
with self.store.open_session() as session:
64+
q = session.advanced.document_query(object_type=UserSkip)
65+
q = q.skip(1).take(10)
66+
results = list(q)
67+
68+
self.assertEqual(
69+
2,
70+
len(results),
71+
f"skip(1).take(10) on 3 documents should return 2, got {len(results)}",
72+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Suggestions: display names containing spaces are quoted in the generated RQL.
3+
4+
C# reference: SlowTests/Issues/RavenDB_20673.cs
5+
CustomizeDisplayNameWithSpaces, CustomizeDisplayNameWithOutSpaces
6+
"""
7+
8+
from ravendb.documents.queries.suggestions import SuggestionBuilder
9+
from ravendb.tests.test_base import TestBase
10+
11+
12+
class User:
13+
def __init__(self, name: str = None):
14+
self.name = name
15+
16+
17+
class TestRavenDB20673(TestBase):
18+
def setUp(self):
19+
super().setUp()
20+
21+
def _setup_data(self):
22+
with self.store.open_session() as session:
23+
session.store(User(name="dan"), "users/1")
24+
session.store(User(name="daniel"), "users/2")
25+
session.store(User(name="danielle"), "users/3")
26+
session.save_changes()
27+
28+
self.wait_for_indexing(self.store)
29+
30+
def test_suggestion_display_name_without_spaces(self):
31+
"""Display names without spaces must work — baseline sanity check."""
32+
self._setup_data()
33+
34+
with self.store.open_session() as session:
35+
36+
def build(b: SuggestionBuilder):
37+
b.by_field("name", "daniele").with_display_name("CustomizedName")
38+
39+
suggestion_query = session.query(object_type=User).suggest_using(build)
40+
rql = suggestion_query.__str__()
41+
self.assertIn("CustomizedName", rql)
42+
43+
results = suggestion_query.execute()
44+
self.assertIn("CustomizedName", results)
45+
self.assertEqual(2, len(results["CustomizedName"].suggestions))
46+
self.assertIn("danielle", results["CustomizedName"].suggestions)
47+
48+
def test_suggestion_display_name_with_spaces(self):
49+
"""Display names containing spaces must be quoted in the RQL."""
50+
self._setup_data()
51+
52+
with self.store.open_session() as session:
53+
54+
def build(b: SuggestionBuilder):
55+
b.by_field("name", "daniele").with_display_name("Customized name with spaces")
56+
57+
suggestion_query = session.query(object_type=User).suggest_using(build)
58+
rql = suggestion_query.__str__()
59+
# escape_if_necessary wraps aliases containing spaces in single quotes
60+
self.assertIn("'Customized name with spaces'", rql)
61+
62+
results = suggestion_query.execute()
63+
self.assertIn("Customized name with spaces", results)
64+
self.assertEqual(2, len(results["Customized name with spaces"].suggestions))
65+
self.assertIn("danielle", results["Customized name with spaces"].suggestions)

0 commit comments

Comments
 (0)