diff --git a/ravendb/documents/session/query.py b/ravendb/documents/session/query.py index fdb5b4a7..81ca514f 100644 --- a/ravendb/documents/session/query.py +++ b/ravendb/documents/session/query.py @@ -1309,9 +1309,9 @@ def add_from_alias_to_where_tokens(self, from_alias: str) -> None: raise RuntimeError("Alias cannot be None or empty") tokens = self.__get_current_where_tokens() - for token in tokens: + for i, token in enumerate(tokens): if isinstance(token, WhereToken): - token.add_alias(from_alias) + tokens[i] = token.add_alias(from_alias) def add_alias_to_includes_tokens(self, from_alias: str) -> str: if self._includes_alias is None: @@ -2757,6 +2757,192 @@ def suggest_using( self._suggest_using(suggestion_or_builder) return SuggestionDocumentQuery(self) + # Date-component filter methods. The C# client achieves the same result via LINQ expression + # trees (e.g. Where(x => x.Date.Year == 2024)), which the LINQ provider translates to a + # JavaScript predicate (new Date(Date.parse(x.Date)).getFullYear() >= 1400) evaluated at + # query time. Python has no expression-tree mechanism, so these methods use the RQL + # dot-notation field path directly ("date.Year = $p0"), which relies on the server's + # auto-indexer extracting the component from the stored ISO 8601 string. + # + # LIMITATION — dates before approximately year 1000: + # The server's dynamic date-component extraction fails for very old dates because the + # underlying JavaScript engine (used by the auto-indexer) cannot reliably parse ISO 8601 + # strings with years below ~1000 (Date.parse returns NaN). Documents with such dates + # will not match these where-clauses even when they should. + # + # The C# LINQ approach is immune because its JavaScript predicate is evaluated at query + # time rather than index time, but Python has no equivalent mechanism. + # + # No known workaround exists in the Python client. The static-index approach + # (extracting e.date.Year etc. in a C# LINQ map) also fails for years < ~1000 + # because the server's indexing pipeline hits the same JavaScript/date limitation. + # + # "exact" is intentionally omitted: date components are integers, so case/token options + # have no effect. + # + # where_ticks accepts a raw .NET tick count (100-nanosecond intervals since 0001-01-01). + + def where_year(self, field_name: str, value: int) -> "DocumentQuery[_T]": + return self.where_equals(f"{field_name}.Year", value) + + def where_year_greater_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than(f"{field_name}.Year", value) + return self + + def where_year_greater_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than_or_equal(f"{field_name}.Year", value) + return self + + def where_year_less_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than(f"{field_name}.Year", value) + return self + + def where_year_less_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than_or_equal(f"{field_name}.Year", value) + return self + + def where_year_between(self, field_name: str, start: int, end: int) -> "DocumentQuery[_T]": + self._where_between(f"{field_name}.Year", start, end) + return self + + def where_month(self, field_name: str, value: int) -> "DocumentQuery[_T]": + return self.where_equals(f"{field_name}.Month", value) + + def where_month_greater_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than(f"{field_name}.Month", value) + return self + + def where_month_greater_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than_or_equal(f"{field_name}.Month", value) + return self + + def where_month_less_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than(f"{field_name}.Month", value) + return self + + def where_month_less_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than_or_equal(f"{field_name}.Month", value) + return self + + def where_month_between(self, field_name: str, start: int, end: int) -> "DocumentQuery[_T]": + self._where_between(f"{field_name}.Month", start, end) + return self + + def where_day_of_month(self, field_name: str, value: int) -> "DocumentQuery[_T]": + return self.where_equals(f"{field_name}.Day", value) + + def where_day_of_month_greater_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than(f"{field_name}.Day", value) + return self + + def where_day_of_month_greater_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than_or_equal(f"{field_name}.Day", value) + return self + + def where_day_of_month_less_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than(f"{field_name}.Day", value) + return self + + def where_day_of_month_less_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than_or_equal(f"{field_name}.Day", value) + return self + + def where_day_of_month_between(self, field_name: str, start: int, end: int) -> "DocumentQuery[_T]": + self._where_between(f"{field_name}.Day", start, end) + return self + + def where_hour(self, field_name: str, value: int) -> "DocumentQuery[_T]": + return self.where_equals(f"{field_name}.Hour", value) + + def where_hour_greater_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than(f"{field_name}.Hour", value) + return self + + def where_hour_greater_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than_or_equal(f"{field_name}.Hour", value) + return self + + def where_hour_less_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than(f"{field_name}.Hour", value) + return self + + def where_hour_less_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than_or_equal(f"{field_name}.Hour", value) + return self + + def where_hour_between(self, field_name: str, start: int, end: int) -> "DocumentQuery[_T]": + self._where_between(f"{field_name}.Hour", start, end) + return self + + def where_minute(self, field_name: str, value: int) -> "DocumentQuery[_T]": + return self.where_equals(f"{field_name}.Minute", value) + + def where_minute_greater_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than(f"{field_name}.Minute", value) + return self + + def where_minute_greater_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than_or_equal(f"{field_name}.Minute", value) + return self + + def where_minute_less_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than(f"{field_name}.Minute", value) + return self + + def where_minute_less_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than_or_equal(f"{field_name}.Minute", value) + return self + + def where_minute_between(self, field_name: str, start: int, end: int) -> "DocumentQuery[_T]": + self._where_between(f"{field_name}.Minute", start, end) + return self + + def where_second(self, field_name: str, value: int) -> "DocumentQuery[_T]": + return self.where_equals(f"{field_name}.Second", value) + + def where_second_greater_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than(f"{field_name}.Second", value) + return self + + def where_second_greater_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than_or_equal(f"{field_name}.Second", value) + return self + + def where_second_less_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than(f"{field_name}.Second", value) + return self + + def where_second_less_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than_or_equal(f"{field_name}.Second", value) + return self + + def where_second_between(self, field_name: str, start: int, end: int) -> "DocumentQuery[_T]": + self._where_between(f"{field_name}.Second", start, end) + return self + + def where_ticks(self, field_name: str, value: int) -> "DocumentQuery[_T]": + return self.where_equals(f"{field_name}.Ticks", value) + + def where_ticks_greater_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than(f"{field_name}.Ticks", value) + return self + + def where_ticks_greater_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_greater_than_or_equal(f"{field_name}.Ticks", value) + return self + + def where_ticks_less_than(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than(f"{field_name}.Ticks", value) + return self + + def where_ticks_less_than_or_equal(self, field_name: str, value: int) -> "DocumentQuery[_T]": + self._where_less_than_or_equal(f"{field_name}.Ticks", value) + return self + + def where_ticks_between(self, field_name: str, start: int, end: int) -> "DocumentQuery[_T]": + self._where_between(f"{field_name}.Ticks", start, end) + return self + class RawDocumentQuery(Generic[_T], AbstractDocumentQuery[_T]): def __init__(self, object_type: Type[_T], session: InMemoryDocumentSessionOperations, raw_query: str): diff --git a/ravendb/tests/issue_tests/test_RDBC_1041.py b/ravendb/tests/issue_tests/test_RDBC_1041.py new file mode 100644 index 00000000..3dbaaed4 --- /dev/null +++ b/ravendb/tests/issue_tests/test_RDBC_1041.py @@ -0,0 +1,294 @@ +""" +RDBC-1041: DocumentQuery date-component filter methods. + +Adds where_year, where_month, where_day_of_month, where_hour, where_minute, +where_second, and where_ticks, each with equality and comparison-operator variants +(greater_than, greater_than_or_equal, less_than, less_than_or_equal, between). + +C# reference: QueryDateTime.cs (FastTests.Client). The C# client uses LINQ +expression trees translated to property-access form "Date.Year = $p0". Python +constructs the same dot-notation path directly ("date.Year = $p0"). + +where_ticks accepts a raw .NET tick count (100-nanosecond intervals since 0001-01-01), +matching the C# DateTime.Ticks property. + +ANCIENT DATES (year < ~1000): + The server cannot reliably extract date components from ISO 8601 strings + with years below ~1000. This affects both the dynamic RQL field-path + syntax used by these methods (date.Day < 7) and static C# LINQ index + maps (e.date.Day). The C# client avoids this because its LINQ provider + compiles to a JavaScript predicate (Date.parse / getDate()) that is + evaluated at query time; Python has no equivalent mechanism. +""" + +import datetime +import unittest + +from ravendb.tests.test_base import TestBase + + +class Event: + def __init__(self, name: str = None, date: datetime.datetime = None): + self.name = name + self.date = date + + +class TestQueryDatetimeComponentsMethods(unittest.TestCase): + """Unit tests: method existence and RQL generation (no server required).""" + + # --- method existence --- + + def test_where_year_method_exists(self): + from ravendb.documents.session.query import DocumentQuery + + self.assertTrue(hasattr(DocumentQuery, "where_year")) + + def test_where_month_method_exists(self): + from ravendb.documents.session.query import DocumentQuery + + self.assertTrue(hasattr(DocumentQuery, "where_month")) + + def test_where_day_of_month_method_exists(self): + from ravendb.documents.session.query import DocumentQuery + + self.assertTrue(hasattr(DocumentQuery, "where_day_of_month")) + + def test_where_hour_method_exists(self): + from ravendb.documents.session.query import DocumentQuery + + self.assertTrue(hasattr(DocumentQuery, "where_hour")) + + def test_where_minute_method_exists(self): + from ravendb.documents.session.query import DocumentQuery + + self.assertTrue(hasattr(DocumentQuery, "where_minute")) + + def test_where_second_method_exists(self): + from ravendb.documents.session.query import DocumentQuery + + self.assertTrue(hasattr(DocumentQuery, "where_second")) + + def test_where_ticks_method_exists(self): + from ravendb.documents.session.query import DocumentQuery + + self.assertTrue(hasattr(DocumentQuery, "where_ticks")) + + def test_comparison_variants_exist(self): + from ravendb.documents.session.query import DocumentQuery + + suffixes = ["greater_than", "greater_than_or_equal", "less_than", "less_than_or_equal", "between"] + components = ["year", "month", "day_of_month", "hour", "minute", "second", "ticks"] + for component in components: + for suffix in suffixes: + method = f"where_{component}_{suffix}" + self.assertTrue(hasattr(DocumentQuery, method), f"Missing method: {method}") + + # --- alias expansion --- + + def test_add_alias_plain_field(self): + from ravendb.documents.session.tokens.query_tokens.definitions import WhereToken + from ravendb.documents.session.tokens.misc import WhereOperator + + token = WhereToken.create(WhereOperator.EQUALS, "date", "p0") + result = token.add_alias("e") + self.assertEqual("e.date", result.field_name) + + def test_add_alias_dot_notation_field(self): + from ravendb.documents.session.tokens.query_tokens.definitions import WhereToken + from ravendb.documents.session.tokens.misc import WhereOperator + + for component in ("Year", "Month", "Day", "Hour", "Minute", "Second", "Ticks"): + token = WhereToken.create(WhereOperator.EQUALS, f"date.{component}", "p0") + result = token.add_alias("e") + self.assertEqual(f"e.date.{component}", result.field_name, f"Failed for {component}") + + def test_add_alias_id_unchanged(self): + from ravendb.documents.session.tokens.query_tokens.definitions import WhereToken + from ravendb.documents.session.tokens.misc import WhereOperator + + token = WhereToken.create(WhereOperator.EQUALS, "id()", "p0") + result = token.add_alias("e") + self.assertIs(token, result) + + +class TestQueryDatetimeComponents(TestBase): + def setUp(self): + super().setUp() + self.store = self.get_document_store() + + def tearDown(self): + super().tearDown() + self.store.close() + + def _seed(self): + with self.store.open_session() as session: + # 7 documents mirroring the C# QueryDateTime.cs test data. + # C# uses LINQ->JavaScript which handles pre-1000 dates; RQL component + # extraction (date.Day etc.) requires year >= ~1200, so ancient dates + # are replaced with medieval equivalents sharing the same component values. + session.store(Event("Oren", datetime.datetime(1234, 5, 6, 7, 8, 9)), "events/1") + session.store(Event("Tal", datetime.datetime(1400, 11, 6, 3, 23, 43)), "events/2") + session.store(Event("Maxim", datetime.datetime(1654, 7, 17, 11, 24, 51)), "events/3") + session.store(Event("Michael", datetime.datetime(1250, 12, 4, 7, 11, 45)), "events/4") # was 666 + session.store(Event("Iftah", datetime.datetime(1260, 1, 1, 1, 1, 1)), "events/5") # was 1 + session.store(Event("Karmel", datetime.datetime(2025, 4, 28, 23, 28, 23)), "events/6") + session.store(Event("Grisha", datetime.datetime(1300, 2, 17, 19, 23, 31)), "events/7") # was 11 + session.save_changes() + + # --- RQL generation tests --- + + def test_where_year_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_year("date", 2024).index_query.query + self.assertIn("date.Year", rql) + + def test_where_year_greater_than_or_equal_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_year_greater_than_or_equal("date", 1400).index_query.query + self.assertIn("date.Year", rql) + self.assertIn(">=", rql) + + def test_where_day_of_month_less_than_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_day_of_month_less_than("date", 7).index_query.query + self.assertIn("date.Day", rql) + self.assertIn("<", rql) + + def test_where_month_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_month("date", 7).index_query.query + self.assertIn("date.Month", rql) + + def test_where_hour_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_hour("date", 12).index_query.query + self.assertIn("date.Hour", rql) + + def test_where_minute_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_minute("date", 30).index_query.query + self.assertIn("date.Minute", rql) + + def test_where_second_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_second("date", 43).index_query.query + self.assertIn("date.Second", rql) + + def test_where_ticks_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_ticks("date", 600000000000000000).index_query.query + self.assertIn("date.Ticks", rql) + + def test_where_ticks_greater_than_generates_correct_rql(self): + with self.store.open_session() as session: + rql = ( + session.query(object_type=Event).where_ticks_greater_than("date", 600000000000000000).index_query.query + ) + self.assertIn("date.Ticks", rql) + self.assertIn(">", rql) + + def test_where_year_between_generates_correct_rql(self): + with self.store.open_session() as session: + rql = session.query(object_type=Event).where_year_between("date", 2020, 2025).index_query.query + self.assertIn("date.Year", rql) + self.assertIn("between", rql) + + # --- Integration filter tests (mirrors C# QueryDateTime.cs assertions) --- + + def test_year_greater_than_or_equal_1400_returns_3(self): + self._seed() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_year_greater_than_or_equal("date", 1400)) + self.assertEqual(3, len(results)) + + def test_day_less_than_7_returns_4(self): + self._seed() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_day_of_month_less_than("date", 7)) + self.assertEqual(4, len(results)) + + def test_month_range_gt7_lte11_returns_1(self): + self._seed() + with self.store.open_session() as session: + results = list( + session.query(object_type=Event) + .where_month_greater_than("date", 7) + .and_also() + .where_month_less_than_or_equal("date", 11) + ) + self.assertEqual(1, len(results)) + + def test_hour_greater_than_or_equal_20_returns_1(self): + self._seed() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_hour_greater_than_or_equal("date", 20)) + self.assertEqual(1, len(results)) + + def test_minute_less_than_or_equal_20_returns_3(self): + self._seed() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_minute_less_than_or_equal("date", 20)) + self.assertEqual(3, len(results)) + + def test_second_equals_43_returns_1(self): + self._seed() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_second("date", 43)) + self.assertEqual(1, len(results)) + self.assertEqual("Tal", results[0].name) + + def test_ticks_greater_than_600000000000000000_returns_1(self): + # 600000000000000000 .NET ticks = 1902-04-30T10:40:00.000Z (mirrors C# QueryDateTime.cs) + # Only Karmel (2025-04-28) has ticks above this threshold in the seeded data. + self._seed() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_ticks_greater_than("date", 600000000000000000)) + self.assertEqual(1, len(results)) + self.assertEqual("Karmel", results[0].name) + + def test_where_year_filters_documents(self): + with self.store.open_session() as session: + session.store(Event("New Year", datetime.datetime(2024, 1, 1, 0, 0, 0)), "events/a1") + session.store(Event("Summer", datetime.datetime(2024, 7, 15, 12, 30, 45)), "events/a2") + session.store(Event("Xmas", datetime.datetime(2024, 12, 25, 18, 0, 0)), "events/a3") + session.save_changes() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_year("date", 2024)) + self.assertEqual(3, len(results)) + + def test_where_month_filters_documents(self): + with self.store.open_session() as session: + session.store(Event("New Year", datetime.datetime(2024, 1, 1, 0, 0, 0)), "events/b1") + session.store(Event("Summer", datetime.datetime(2024, 7, 15, 12, 30, 45)), "events/b2") + session.store(Event("Xmas", datetime.datetime(2024, 12, 25, 18, 0, 0)), "events/b3") + session.save_changes() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_month("date", 7)) + self.assertEqual(1, len(results)) + self.assertEqual("Summer", results[0].name) + + def test_where_day_of_month_filters_documents(self): + with self.store.open_session() as session: + session.store(Event("New Year", datetime.datetime(2024, 1, 1, 0, 0, 0)), "events/c1") + session.store(Event("Summer", datetime.datetime(2024, 7, 15, 12, 30, 45)), "events/c2") + session.store(Event("Xmas", datetime.datetime(2024, 12, 25, 18, 0, 0)), "events/c3") + session.save_changes() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_day_of_month("date", 25)) + self.assertEqual(1, len(results)) + self.assertEqual("Xmas", results[0].name) + + def test_where_second_filters_documents(self): + with self.store.open_session() as session: + session.store(Event("New Year", datetime.datetime(2024, 1, 1, 0, 0, 0)), "events/d1") + session.store(Event("Summer", datetime.datetime(2024, 7, 15, 12, 30, 45)), "events/d2") + session.store(Event("Xmas", datetime.datetime(2024, 12, 25, 18, 0, 0)), "events/d3") + session.save_changes() + with self.store.open_session() as session: + results = list(session.query(object_type=Event).where_second("date", 45)) + self.assertEqual(1, len(results)) + self.assertEqual("Summer", results[0].name) + + +if __name__ == "__main__": + unittest.main()