From b601d8f7e2b4324d794de1987a6e49e6afa3c397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 07:45:46 +0100 Subject: [PATCH 01/19] Support for less than or equal, and greater than or equal --- terminusdb_client/woqlquery/woql_query.py | 58 ++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 79463df4..85a4a63b 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2143,9 +2143,9 @@ def less(self, left, right): Parameters ---------- - left : str + left : str or number first variable to compare - right : str + right : str or number second variable to compare Returns @@ -2155,6 +2155,8 @@ def less(self, left, right): """ if left and left == "args": return ["left", "right"] + if left is None or right is None: + raise ValueError("Less takes two parameters") if self._cursor.get("@type"): self._wrap_cursor_with_and() self._cursor["@type"] = "Less" @@ -2167,9 +2169,9 @@ def greater(self, left, right): Parameters ---------- - left : str + left : str or number first variable to compare - right : str + right : str or number second variable to compare Returns @@ -2177,8 +2179,8 @@ def greater(self, left, right): WOQLQuery object query object that can be chained and/or execute """ - if left and left == "args": - return ["left", "right"] + if left is None or right is None: + raise ValueError("Greater takes two parameters") if self._cursor.get("@type"): self._wrap_cursor_with_and() self._cursor["@type"] = "Greater" @@ -2186,6 +2188,50 @@ def greater(self, left, right): self._cursor["right"] = self._clean_object(right) return self + def gte(self, left, right): + """Compares two values using greater-than-or-equal ordering. + Parameters + ---------- + left : str or number + the greater or equal value + right : str or number + the lesser or equal value + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if left is None or right is None: + raise ValueError("Gte takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "Gte" + self._cursor["left"] = self._clean_object(left) + self._cursor["right"] = self._clean_object(right) + return self + + def lte(self, left, right): + """Compares two values using less-than-or-equal ordering. + Parameters + ---------- + left : str or number + the lesser or equal value + right : str or number + the greater or equal value + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if left is None or right is None: + raise ValueError("Lte takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "Lte" + self._cursor["left"] = self._clean_object(left) + self._cursor["right"] = self._clean_object(right) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From d3288f0945bda7751a1587cbeef399633a2d36ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 07:46:02 +0100 Subject: [PATCH 02/19] Standardized test configuration --- .../tests/integration_tests/conftest.py | 39 ++++++------------- .../integration_tests/test-docker-compose.yml | 16 +++++--- .../tests/integration_tests/test_conftest.py | 16 ++++---- terminusdb_client/tests/test_Schema.py | 12 +++--- 4 files changed, 36 insertions(+), 47 deletions(-) diff --git a/terminusdb_client/tests/integration_tests/conftest.py b/terminusdb_client/tests/integration_tests/conftest.py index 4a70bfed..75bf84ce 100644 --- a/terminusdb_client/tests/integration_tests/conftest.py +++ b/terminusdb_client/tests/integration_tests/conftest.py @@ -12,20 +12,17 @@ def is_local_server_running(): """Check if local TerminusDB server is running at http://127.0.0.1:6363""" try: - response = requests.get("http://127.0.0.1:6363", timeout=2) - # Server responds with 200 (success) or 404 (not found but server is up) - # 401 (unauthorized) also indicates server is running but needs auth - return response.status_code in [200, 404] + response = requests.get("http://127.0.0.1:6363/api/ok", timeout=2) + return response.status_code == 200 except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): return False def is_docker_server_running(): - """Check if Docker TerminusDB server is already running at http://127.0.0.1:6366""" + """Check if Docker TerminusDB server is already running at http://127.0.0.1:6363""" try: - response = requests.get("http://127.0.0.1:6366", timeout=2) - # Server responds with 404 for root path, which means it's running - return response.status_code in [200, 404] + response = requests.get("http://127.0.0.1:6363/api/ok", timeout=2) + return response.status_code == 200 except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): return False @@ -143,33 +140,19 @@ def docker_url_jwt(pytestconfig): def docker_url(pytestconfig): """ Provides a TerminusDB server URL for integration tests. - Prefers local test server if running, otherwise starts Docker container. - - NOTE: This fixture returns just the URL. Tests expect AUTOLOGIN mode (no authentication). - If using local server with authentication, use TERMINUSDB_AUTOLOGIN=true when starting it. + Uses port 6363 with admin:root authentication by default. + Prefers an already-running server, otherwise starts a Docker container. """ - # Check if local test server is already running (port 6363) + # Check if a server is already running (port 6363) if is_local_server_running(): print( - "\n✓ Using existing local TerminusDB test server at http://127.0.0.1:6363" - ) - print( - "⚠️ WARNING: Local server should be started with TERMINUSDB_AUTOLOGIN=true" - ) - print( - " Or use: TERMINUSDB_SERVER_AUTOLOGIN=true ./tests/terminusdb-test-server.sh restart" + "\n✓ Using existing TerminusDB server at http://127.0.0.1:6363" ) yield "http://127.0.0.1:6363" return # Don't clean up - server was already running - # Check if Docker container is already running (port 6366) - if is_docker_server_running(): - print("\n✓ Using existing Docker TerminusDB server at http://127.0.0.1:6366") - yield "http://127.0.0.1:6366" - return # Don't clean up - server was already running - # No server found, start Docker container - print("\n⚠ No server found, starting Docker container with AUTOLOGIN...") + print("\n⚠ No server found, starting Docker container...") pytestconfig.getoption("docker_compose") output = subprocess.run( [ @@ -185,7 +168,7 @@ def docker_url(pytestconfig): if output.returncode != 0: raise RuntimeError(output.stderr) - test_url = "http://127.0.0.1:6366" + test_url = "http://127.0.0.1:6363" is_server_started = False seconds_waited = 0 diff --git a/terminusdb_client/tests/integration_tests/test-docker-compose.yml b/terminusdb_client/tests/integration_tests/test-docker-compose.yml index 43e21b40..79ca39d9 100644 --- a/terminusdb_client/tests/integration_tests/test-docker-compose.yml +++ b/terminusdb_client/tests/integration_tests/test-docker-compose.yml @@ -9,21 +9,27 @@ services: hostname: terminusdb-server tty: true ports: - - 6366:6366 + - 6363:6363 environment: - TERMINUSDB_SERVER_NAME=http://127.0.0.1 - - TERMINUSDB_SERVER_PORT=6366 + - TERMINUSDB_SERVER_PORT=6363 # There are multiple ways to configure TerminusDB security through # environment variables. Several reasonable options are included below. # Uncomment the option you decide on and comment out others. # Don't forget to change the default password! - # Security Option 1 (default): Assumes TerminusDB is only accessible from + # Security Option 1 (default): Use a password for the login + - TERMINUSDB_ADMIN_PASS=root + - TERMINUSDB_AUTOLOGIN=false + - TERMINUSDB_SERVER_PORT=6363 + - TERMINUSDB_HTTPS_ENABLED=false + + # Security Option 2: Assumes TerminusDB is only accessible from # the machine it's running on and all access to port 6363 is considered # authorized. - - TERMINUSDB_HTTPS_ENABLED=false - - TERMINUSDB_AUTOLOGIN=true + # - TERMINUSDB_HTTPS_ENABLED=false + # - TERMINUSDB_AUTOLOGIN=true # Security Option 2: TerminusDB is set up behind a TLS-terminating reverse # proxy with admin authentication provided by password. diff --git a/terminusdb_client/tests/integration_tests/test_conftest.py b/terminusdb_client/tests/integration_tests/test_conftest.py index 26d4407f..f9ccc0ec 100644 --- a/terminusdb_client/tests/integration_tests/test_conftest.py +++ b/terminusdb_client/tests/integration_tests/test_conftest.py @@ -21,16 +21,16 @@ def test_local_server_running_200(self, mock_get): mock_get.return_value = mock_response assert is_local_server_running() is True - mock_get.assert_called_once_with("http://127.0.0.1:6363", timeout=2) + mock_get.assert_called_once_with("http://127.0.0.1:6363/api/ok", timeout=2) @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") - def test_local_server_running_404(self, mock_get): - """Test local server detection returns True for HTTP 404""" + def test_local_server_running_not_200(self, mock_get): + """Test local server detection returns False for non-200 status""" mock_response = Mock() mock_response.status_code = 404 mock_get.return_value = mock_response - assert is_local_server_running() is True + assert is_local_server_running() is False @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") def test_local_server_not_running_connection_error(self, mock_get): @@ -54,16 +54,16 @@ def test_docker_server_running_200(self, mock_get): mock_get.return_value = mock_response assert is_docker_server_running() is True - mock_get.assert_called_once_with("http://127.0.0.1:6366", timeout=2) + mock_get.assert_called_once_with("http://127.0.0.1:6363/api/ok", timeout=2) @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") - def test_docker_server_running_404(self, mock_get): - """Test Docker server detection returns True for HTTP 404""" + def test_docker_server_running_not_200(self, mock_get): + """Test Docker server detection returns False for non-200 status""" mock_response = Mock() mock_response.status_code = 404 mock_get.return_value = mock_response - assert is_docker_server_running() is True + assert is_docker_server_running() is False @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") def test_docker_server_not_running(self, mock_get): diff --git a/terminusdb_client/tests/test_Schema.py b/terminusdb_client/tests/test_Schema.py index 0fd41e95..3e439a0e 100644 --- a/terminusdb_client/tests/test_Schema.py +++ b/terminusdb_client/tests/test_Schema.py @@ -200,7 +200,7 @@ def test_embedded_object(test_schema): ), friend_of={Person(name="Katy", age=51)}, ) - client = Client("http://127.0.0.1:6366") + client = Client("http://127.0.0.1:6363") result = client._convert_document(gavin, "instance") # Finds the internal object and splays it out properly assert len(result) == 2 @@ -334,11 +334,11 @@ def test_compress_data(patched, patched2, patched3, patched4): weeks=2, ) test_obj = [CheckDatetime(datetime=datetime_obj, duration=delta) for _ in range(10)] - client = Client("http://127.0.0.1:6366") + client = Client("http://127.0.0.1:6363") client.connect(db="test_compress_data") client.insert_document(test_obj, compress=0) client._session.post.assert_called_once_with( - "http://127.0.0.1:6366/api/document/admin/test_compress_data/local/branch/main", + "http://127.0.0.1:6363/api/document/admin/test_compress_data/local/branch/main", auth=("admin", "root"), headers={ "user-agent": f"terminusdb-client-python/{__version__}", @@ -357,7 +357,7 @@ def test_compress_data(patched, patched2, patched3, patched4): client._session.post.reset_mock() client.insert_document(test_obj, compress="never") client._session.post.assert_called_once_with( - "http://127.0.0.1:6366/api/document/admin/test_compress_data/local/branch/main", + "http://127.0.0.1:6363/api/document/admin/test_compress_data/local/branch/main", auth=("admin", "root"), headers={"user-agent": f"terminusdb-client-python/{__version__}"}, params={ @@ -371,7 +371,7 @@ def test_compress_data(patched, patched2, patched3, patched4): ) client.replace_document(test_obj, compress=0) client._session.put.assert_called_once_with( - "http://127.0.0.1:6366/api/document/admin/test_compress_data/local/branch/main", + "http://127.0.0.1:6363/api/document/admin/test_compress_data/local/branch/main", auth=("admin", "root"), headers={ "user-agent": f"terminusdb-client-python/{__version__}", @@ -390,7 +390,7 @@ def test_compress_data(patched, patched2, patched3, patched4): client._session.put.reset_mock() client.replace_document(test_obj, compress="never") client._session.put.assert_called_once_with( - "http://127.0.0.1:6366/api/document/admin/test_compress_data/local/branch/main", + "http://127.0.0.1:6363/api/document/admin/test_compress_data/local/branch/main", auth=("admin", "root"), headers={"user-agent": f"terminusdb-client-python/{__version__}"}, params={ From fc2c32693476f2f0761d45c367419b70d9dfa99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 07:51:26 +0100 Subject: [PATCH 03/19] Add in_range operator --- terminusdb_client/woqlquery/woql_query.py | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 85a4a63b..d53c4350 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2232,6 +2232,34 @@ def lte(self, left, right): self._cursor["right"] = self._clean_object(right) return self + def in_range(self, value, start, end): + """Tests whether a value falls within a half-open range [start, end). + Succeeds if start <= value < end. + + Parameters + ---------- + value : str or number + the value to test + start : str or number + the inclusive lower bound + end : str or number + the exclusive upper bound + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if value is None or start is None or end is None: + raise ValueError("InRange takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "InRange" + self._cursor["value"] = self._clean_object(value) + self._cursor["start"] = self._clean_object(start) + self._cursor["end"] = self._clean_object(end) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From 66921e1bcdae939685d80840d3c132ef05e1ecc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 08:26:49 +0100 Subject: [PATCH 04/19] Add sequence operator --- terminusdb_client/woqlquery/woql_query.py | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index d53c4350..4d5a5402 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2260,6 +2260,42 @@ def in_range(self, value, start, end): self._cursor["end"] = self._clean_object(end) return self + def sequence(self, value, start, end, step=None, count=None): + """Generates a sequence of values in the half-open range [start, end). + When value is unbound, produces each value via backtracking. + + Parameters + ---------- + value : str or number + the generated sequence value (or variable) + start : str or number + the inclusive start of the sequence + end : str or number + the exclusive end of the sequence + step : str or number, optional + increment per step + count : str or number, optional + total count (validates if bound, unifies if unbound) + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if value is None or start is None or end is None: + raise ValueError("Sequence takes at least three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "Sequence" + self._cursor["value"] = self._clean_object(value) + self._cursor["start"] = self._clean_object(start) + self._cursor["end"] = self._clean_object(end) + if step is not None: + self._cursor["step"] = self._clean_object(step) + if count is not None: + self._cursor["count"] = self._clean_object(count) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From 24acafed68270348c8587c779d1196e0102cbe05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 08:50:08 +0100 Subject: [PATCH 05/19] Handle iso8601 month start and end logic --- terminusdb_client/woqlquery/woql_query.py | 103 ++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 4d5a5402..829aef1d 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2296,6 +2296,109 @@ def sequence(self, value, start, end, step=None, count=None): self._cursor["count"] = self._clean_object(count) return self + def month_start_date(self, year_month, date): + """Computes the first day of the month for a given xsd:gYearMonth. + + Parameters + ---------- + year_month : str or dict + a gYearMonth value (e.g. 2024-01) or variable + date : str or dict + the resulting xsd:date or variable + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if year_month is None or date is None: + raise ValueError("MonthStartDate takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "MonthStartDate" + self._cursor["year_month"] = self._clean_object(year_month) + self._cursor["date"] = self._clean_object(date) + return self + + def month_end_date(self, year_month, date): + """Computes the last day of the month for a given xsd:gYearMonth. + Handles leap years correctly. + + Parameters + ---------- + year_month : str or dict + a gYearMonth value (e.g. 2024-02) or variable + date : str or dict + the resulting xsd:date or variable + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if year_month is None or date is None: + raise ValueError("MonthEndDate takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "MonthEndDate" + self._cursor["year_month"] = self._clean_object(year_month) + self._cursor["date"] = self._clean_object(date) + return self + + def month_start_dates(self, date, start, end): + """Generator: produces every first-of-month date in [start, end). + + Parameters + ---------- + date : str + variable for the generated first-of-month date + start : str or dict + the inclusive start date + end : str or dict + the exclusive end date + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if date is None or start is None or end is None: + raise ValueError("MonthStartDates takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "MonthStartDates" + self._cursor["date"] = self._clean_object(date) + self._cursor["start"] = self._clean_object(start) + self._cursor["end"] = self._clean_object(end) + return self + + def month_end_dates(self, date, start, end): + """Generator: produces every last-of-month date in [start, end). + + Parameters + ---------- + date : str + variable for the generated last-of-month date + start : str or dict + the inclusive start date + end : str or dict + the exclusive end date + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if date is None or start is None or end is None: + raise ValueError("MonthEndDates takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "MonthEndDates" + self._cursor["date"] = self._clean_object(date) + self._cursor["start"] = self._clean_object(start) + self._cursor["end"] = self._clean_object(end) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From 36f038e68ff9e37381cf151cdc3f5e93b3cf179a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 10:36:35 +0100 Subject: [PATCH 06/19] Add Allen temporal interval relationship matcher and generator --- terminusdb_client/woqlquery/woql_query.py | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 829aef1d..7ae4847d 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2399,6 +2399,45 @@ def month_end_dates(self, date, start, end): self._cursor["end"] = self._clean_object(end) return self + def interval_relation(self, relation, x_start, x_end, y_start, y_end): + """Allen's Interval Algebra: classifies or validates the relationship + between two half-open intervals [x_start, x_end) and [y_start, y_end). + + When relation is a string, validates that the named relation holds. + When relation is a variable, classifies which of the 13 Allen + relations holds (deterministic). + + Parameters + ---------- + relation : str or dict + relation name (e.g. "before") or variable for classification + x_start : str or dict + inclusive start of interval X + x_end : str or dict + exclusive end of interval X + y_start : str or dict + inclusive start of interval Y + y_end : str or dict + exclusive end of interval Y + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if (relation is None or x_start is None or x_end is None + or y_start is None or y_end is None): + raise ValueError("IntervalRelation takes five parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "IntervalRelation" + self._cursor["relation"] = self._clean_object(relation) + self._cursor["x_start"] = self._clean_object(x_start) + self._cursor["x_end"] = self._clean_object(x_end) + self._cursor["y_start"] = self._clean_object(y_start) + self._cursor["y_end"] = self._clean_object(y_end) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From f6f313dce3143dfabf8096021786799460505030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 11:02:50 +0100 Subject: [PATCH 07/19] Add day_after/day_before --- terminusdb_client/woqlquery/woql_query.py | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 7ae4847d..66e9a869 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2438,6 +2438,54 @@ def interval_relation(self, relation, x_start, x_end, y_start, y_end): self._cursor["y_end"] = self._clean_object(y_end) return self + def day_after(self, date, next_date): + """Computes the calendar day after the given date. Bidirectional. + + Parameters + ---------- + date : str or dict + the input date + next_date : str or dict + the next calendar day + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if date is None or next_date is None: + raise ValueError("DayAfter takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "DayAfter" + self._cursor["date"] = self._clean_object(date) + self._cursor["next"] = self._clean_object(next_date) + return self + + def day_before(self, date, previous): + """Computes the calendar day before the given date. Bidirectional. + + Parameters + ---------- + date : str or dict + the input date + previous : str or dict + the previous calendar day + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if date is None or previous is None: + raise ValueError("DayBefore takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "DayBefore" + self._cursor["date"] = self._clean_object(date) + self._cursor["previous"] = self._clean_object(previous) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From b32386ed6b331a720c86041a85a86b268d5bd365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 11:21:05 +0100 Subject: [PATCH 08/19] Correct intervals handling --- terminusdb_client/woqlquery/woql_query.py | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 66e9a869..ed2f3d1f 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2438,6 +2438,36 @@ def interval_relation(self, relation, x_start, x_end, y_start, y_end): self._cursor["y_end"] = self._clean_object(y_end) return self + def interval(self, start, end, interval_val): + """Constructs or deconstructs a half-open xdd:dateTimeInterval [start, end). + + Bidirectional: given start+end computes interval, given interval + extracts start+end. + + Parameters + ---------- + start : str or dict + inclusive start date + end : str or dict + exclusive end date + interval_val : str or dict + the xdd:dateTimeInterval value + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if start is None or end is None or interval_val is None: + raise ValueError("Interval takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "Interval" + self._cursor["start"] = self._clean_object(start) + self._cursor["end"] = self._clean_object(end) + self._cursor["interval"] = self._clean_object(interval_val) + return self + def day_after(self, date, next_date): """Computes the calendar day after the given date. Bidirectional. From 512f684c0a4ade749a360f7946b2f3e04782ecbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 12:41:04 +0100 Subject: [PATCH 09/19] New type, xdd:dateTimeInterval for iso8601 interval support --- terminusdb_client/woqlquery/woql_query.py | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index ed2f3d1f..ab3e98e6 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2468,6 +2468,66 @@ def interval(self, start, end, interval_val): self._cursor["interval"] = self._clean_object(interval_val) return self + def interval_start_duration(self, start, duration, interval_val): + """Relates an xdd:dateTimeInterval to its start endpoint and precise xsd:duration. + + Bidirectional: given interval extracts start+duration, given + start+duration computes interval. + + Parameters + ---------- + start : str or dict + inclusive start date or dateTime + duration : str or dict + the xsd:duration between start and end + interval_val : str or dict + the xdd:dateTimeInterval value + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if start is None or duration is None or interval_val is None: + raise ValueError("IntervalStartDuration takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "IntervalStartDuration" + self._cursor["start"] = self._clean_object(start) + self._cursor["duration"] = self._clean_object(duration) + self._cursor["interval"] = self._clean_object(interval_val) + return self + + def interval_duration_end(self, duration, end, interval_val): + """Relates an xdd:dateTimeInterval to its end endpoint and precise xsd:duration. + + Bidirectional: given interval extracts duration+end, given + duration+end computes interval. + + Parameters + ---------- + duration : str or dict + the xsd:duration between start and end + end : str or dict + exclusive end date or dateTime + interval_val : str or dict + the xdd:dateTimeInterval value + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if duration is None or end is None or interval_val is None: + raise ValueError("IntervalDurationEnd takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "IntervalDurationEnd" + self._cursor["duration"] = self._clean_object(duration) + self._cursor["end"] = self._clean_object(end) + self._cursor["interval"] = self._clean_object(interval_val) + return self + def day_after(self, date, next_date): """Computes the calendar day after the given date. Bidirectional. From b617efab081853b69d5c72e886eb8215637c96e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 14:05:52 +0100 Subject: [PATCH 10/19] Initial iso8601 interval processing --- .../test_woql_interval_duration.py | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 terminusdb_client/tests/integration_tests/test_woql_interval_duration.py diff --git a/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py b/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py new file mode 100644 index 00000000..2e5af526 --- /dev/null +++ b/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py @@ -0,0 +1,193 @@ +""" +Integration tests for WOQL interval duration predicates. + +These tests verify the new interval features: +- Interval with xsd:dateTime endpoints +- IntervalStartDuration (start + duration decomposition) +- IntervalDurationEnd (duration + end decomposition) +- Roundtrip consistency across all three interval views +""" + +import pytest + +from terminusdb_client import Client +from terminusdb_client.woqlquery.woql_query import WOQLQuery + +test_user_agent = "terminusdb-client-python-tests" + + +def dat(v): + """Build an xsd:date typed literal.""" + return {"@type": "xsd:date", "@value": v} + + +def dtm(v): + """Build an xsd:dateTime typed literal.""" + return {"@type": "xsd:dateTime", "@value": v} + + +def dur(v): + """Build an xsd:duration typed literal.""" + return {"@type": "xsd:duration", "@value": v} + + +def dti(v): + """Build an xdd:dateTimeInterval typed literal.""" + return {"@type": "xdd:dateTimeInterval", "@value": v} + + +class TestIntervalDateTimeEndpoints: + """Tests for Interval predicate with xsd:dateTime endpoints.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_interval_duration" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_construct_from_datetime_endpoints(self): + """Construct interval from two xsd:dateTime values.""" + query = WOQLQuery().interval(dtm("2025-01-01T09:00:00Z"), + dtm("2025-01-01T17:30:00Z"), + "v:iv") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["iv"]["@value"] == \ + "2025-01-01T09:00:00Z/2025-01-01T17:30:00Z" + + def test_construct_mixed_date_datetime(self): + """Construct interval with date start and dateTime end.""" + query = WOQLQuery().interval(dat("2025-01-01"), + dtm("2025-04-01T12:00:00Z"), + "v:iv") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["iv"]["@value"] == \ + "2025-01-01/2025-04-01T12:00:00Z" + + def test_deconstruct_datetime_interval(self): + """Deconstruct dateTime interval preserves types.""" + query = WOQLQuery().interval("v:s", "v:e", + dti("2025-01-01T09:00:00Z/2025-04-01T17:30:00Z")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["s"]["@type"] == "xsd:dateTime" + assert result["bindings"][0]["e"]["@type"] == "xsd:dateTime" + + +class TestIntervalStartDuration: + """Tests for IntervalStartDuration predicate.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_interval_start_dur" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_extract_start_and_duration_from_date_interval(self): + """Extract start + P90D duration from a 90-day interval.""" + query = WOQLQuery().interval_start_duration( + "v:s", "v:d", dti("2025-01-01/2025-04-01")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["s"]["@value"] == "2025-01-01" + assert result["bindings"][0]["d"]["@value"] == "P90D" + + def test_extract_sub_day_duration_from_datetime_interval(self): + """Extract PT8H30M duration from a sub-day dateTime interval.""" + query = WOQLQuery().interval_start_duration( + "v:s", "v:d", + dti("2025-01-01T09:00:00Z/2025-01-01T17:30:00Z")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["s"]["@type"] == "xsd:dateTime" + assert result["bindings"][0]["d"]["@value"] == "PT8H30M" + + def test_construct_interval_from_start_and_duration(self): + """Construct [Jan 1, Apr 1) from start date + P90D.""" + query = WOQLQuery().interval_start_duration( + dat("2025-01-01"), dur("P90D"), "v:iv") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["iv"]["@value"] == "2025-01-01/2025-04-01" + + def test_construct_full_year_interval(self): + """Construct a 365-day interval from start + P365D.""" + query = WOQLQuery().interval_start_duration( + dat("2025-01-01"), dur("P365D"), "v:iv") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["iv"]["@value"] == "2025-01-01/2026-01-01" + + +class TestIntervalDurationEnd: + """Tests for IntervalDurationEnd predicate.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_interval_dur_end" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_extract_duration_and_end_from_interval(self): + """Extract P90D + end date from a 90-day interval.""" + query = WOQLQuery().interval_duration_end( + "v:d", "v:e", dti("2025-01-01/2025-04-01")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["e"]["@value"] == "2025-04-01" + assert result["bindings"][0]["d"]["@value"] == "P90D" + + def test_construct_interval_from_duration_and_end(self): + """Construct [Jan 1, Apr 1) from P90D + end date.""" + query = WOQLQuery().interval_duration_end( + dur("P90D"), dat("2025-04-01"), "v:iv") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["iv"]["@value"] == "2025-01-01/2025-04-01" + + +class TestIntervalThreeViewsRoundtrip: + """Roundtrip test: all three decomposition views of the same interval agree.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_interval_roundtrip" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_three_views_agree(self): + """start/end, start/duration, and duration/end all agree.""" + iv = dti("2025-01-01/2025-04-01") + query = WOQLQuery().woql_and( + WOQLQuery().interval("v:s1", "v:e1", iv), + WOQLQuery().interval_start_duration("v:s2", "v:d2", iv), + WOQLQuery().interval_duration_end("v:d3", "v:e3", iv), + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + b = result["bindings"][0] + assert b["s1"]["@value"] == b["s2"]["@value"] + assert b["e1"]["@value"] == b["e3"]["@value"] + assert b["d2"]["@value"] == b["d3"]["@value"] + assert b["d2"]["@value"] == "P90D" From 8fd8f16b28624ddbdf9422e4b0fcbe12e39bcb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 20:13:00 +0100 Subject: [PATCH 11/19] Weekday support --- terminusdb_client/woqlquery/woql_query.py | 79 +++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index ab3e98e6..deb429a9 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2576,6 +2576,85 @@ def day_before(self, date, previous): self._cursor["previous"] = self._clean_object(previous) return self + def weekday(self, date, weekday): + """Computes the ISO 8601 weekday number (Monday=1, Sunday=7) for a date. + Accepts xsd:date or xsd:dateTime. Date must be ground. + + Parameters + ---------- + date : str or dict + the input date or dateTime + weekday : str or int + the ISO weekday number (1=Monday, 7=Sunday) + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if date is None or weekday is None: + raise ValueError("Weekday takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "Weekday" + self._cursor["date"] = self._clean_object(date) + self._cursor["weekday"] = self._clean_object(weekday) + return self + + def weekday_sunday_start(self, date, weekday): + """Computes the US-convention weekday number (Sunday=1, Saturday=7) for a date. + Accepts xsd:date or xsd:dateTime. Date must be ground. + + Parameters + ---------- + date : str or dict + the input date or dateTime + weekday : str or int + the US weekday number (1=Sunday, 7=Saturday) + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if date is None or weekday is None: + raise ValueError("WeekdaySundayStart takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "WeekdaySundayStart" + self._cursor["date"] = self._clean_object(date) + self._cursor["weekday"] = self._clean_object(weekday) + return self + + def iso_week(self, date, year, week): + """Computes the ISO 8601 week-numbering year and week number for a date. + Accepts xsd:date or xsd:dateTime. Date must be ground. + The ISO year may differ from the calendar year at year boundaries. + + Parameters + ---------- + date : str or dict + the input date or dateTime + year : str or int + the ISO week-numbering year + week : str or int + the ISO week number (1-53) + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if date is None or year is None or week is None: + raise ValueError("IsoWeek takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "IsoWeek" + self._cursor["date"] = self._clean_object(date) + self._cursor["year"] = self._clean_object(year) + self._cursor["week"] = self._clean_object(week) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From c9079448ee1a42ab6ef6773a04831a8b9c42ab4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 21:05:17 +0100 Subject: [PATCH 12/19] Add date_duration generator/matcher --- terminusdb_client/woqlquery/woql_query.py | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index deb429a9..5bd4767b 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2655,6 +2655,36 @@ def iso_week(self, date, year, week): self._cursor["week"] = self._clean_object(week) return self + def date_duration(self, start, end, duration): + """Tri-directional duration arithmetic for dates and dateTimes. + Given any two of start, end, and duration, computes the third. + Uses EOM preservation: last day of month maps to last day of target month. + Accepts xsd:date or xsd:dateTime for start/end, xsd:duration for duration. + + Parameters + ---------- + start : str or dict + the start date or dateTime + end : str or dict + the end date or dateTime + duration : str or dict + the xsd:duration between start and end + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if start is None or end is None or duration is None: + raise ValueError("DateDuration takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "DateDuration" + self._cursor["start"] = self._clean_object(start) + self._cursor["end"] = self._clean_object(end) + self._cursor["duration"] = self._clean_object(duration) + return self + def opt(self, query=None): """The Query in the Optional argument is specified as optional From 9a4868286bb544868ba372f31c0202e92db0f504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 21:58:26 +0100 Subject: [PATCH 13/19] Add typed interval_duration --- terminusdb_client/woqlquery/woql_query.py | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 5bd4767b..5968e2b8 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2438,6 +2438,38 @@ def interval_relation(self, relation, x_start, x_end, y_start, y_end): self._cursor["y_end"] = self._clean_object(y_end) return self + def interval_relation_typed(self, relation, x, y): + """Allen's Interval Algebra on xdd:dateTimeInterval values. + + Classifies or validates the temporal relationship between two + interval values. When relation is ground, validates that the named + relation holds. When relation is a variable, determines which of the + 13 Allen relations holds (deterministic). + + Parameters + ---------- + relation : str or dict + relation name (e.g. "before") or variable for classification + x : str or dict + first xdd:dateTimeInterval value + y : str or dict + second xdd:dateTimeInterval value + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if relation is None or x is None or y is None: + raise ValueError("IntervalRelationTyped takes three parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "IntervalRelationTyped" + self._cursor["relation"] = self._clean_object(relation) + self._cursor["x"] = self._clean_object(x) + self._cursor["y"] = self._clean_object(y) + return self + def interval(self, start, end, interval_val): """Constructs or deconstructs a half-open xdd:dateTimeInterval [start, end). From ba1decf312bcd4c6d533c0911f9e43e85c7075ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 21:58:47 +0100 Subject: [PATCH 14/19] Add missing tests --- .../test_woql_date_duration.py | 225 ++++++++++++++++++ .../test_woql_interval_relation_typed.py | 150 ++++++++++++ .../tests/test_woql_date_duration.py | 84 +++++++ .../test_woql_interval_relation_typed.py | 59 +++++ 4 files changed, 518 insertions(+) create mode 100644 terminusdb_client/tests/integration_tests/test_woql_date_duration.py create mode 100644 terminusdb_client/tests/integration_tests/test_woql_interval_relation_typed.py create mode 100644 terminusdb_client/tests/test_woql_date_duration.py create mode 100644 terminusdb_client/tests/test_woql_interval_relation_typed.py diff --git a/terminusdb_client/tests/integration_tests/test_woql_date_duration.py b/terminusdb_client/tests/integration_tests/test_woql_date_duration.py new file mode 100644 index 00000000..40bb8649 --- /dev/null +++ b/terminusdb_client/tests/integration_tests/test_woql_date_duration.py @@ -0,0 +1,225 @@ +""" +Integration tests for WOQL DateDuration predicate. + +Tests the tri-directional DateDuration(Start, End, Duration) predicate: +- Start + End → Duration (compute duration from two dates) +- Start + Duration → End (EOM-aware addition) +- Duration + End → Start (EOM-aware subtraction) +- All three ground (validation) +- EOM reversibility +""" + +import pytest + +from terminusdb_client import Client +from terminusdb_client.woqlquery.woql_query import WOQLQuery + +test_user_agent = "terminusdb-client-python-tests" + + +def dat(v): + """Build an xsd:date typed literal.""" + return {"@type": "xsd:date", "@value": v} + + +def dtm(v): + """Build an xsd:dateTime typed literal.""" + return {"@type": "xsd:dateTime", "@value": v} + + +def dur(v): + """Build an xsd:duration typed literal.""" + return {"@type": "xsd:duration", "@value": v} + + +class TestDateDurationComputeDuration: + """Start + End → Duration.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_date_dur_compute" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_leap_year_91_days(self): + """2024-01-01 to 2024-04-01 = P91D (leap year).""" + query = WOQLQuery().date_duration(dat("2024-01-01"), dat("2024-04-01"), "v:d") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["d"]["@value"] == "P91D" + + def test_non_leap_year_90_days(self): + """2025-01-01 to 2025-04-01 = P90D (non-leap year).""" + query = WOQLQuery().date_duration(dat("2025-01-01"), dat("2025-04-01"), "v:d") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["d"]["@value"] == "P90D" + + def test_zero_duration(self): + """Same date produces P0D.""" + query = WOQLQuery().date_duration(dat("2024-01-01"), dat("2024-01-01"), "v:d") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["d"]["@value"] == "P0D" + + def test_datetime_time_difference(self): + """dateTime with sub-day difference produces PT9H30M.""" + query = WOQLQuery().date_duration( + dtm("2024-01-01T08:00:00Z"), dtm("2024-01-01T17:30:00Z"), "v:d" + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["d"]["@value"] == "PT9H30M" + + def test_datetime_midnight_to_midnight(self): + """Midnight-to-midnight omits time component: P3D.""" + query = WOQLQuery().date_duration( + dtm("2024-01-01T00:00:00Z"), dtm("2024-01-04T00:00:00Z"), "v:d" + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["d"]["@value"] == "P3D" + + +class TestDateDurationAddition: + """Start + Duration → End (EOM-aware).""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_date_dur_add" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_jan31_plus_1m_leap(self): + """Jan 31 + P1M = Feb 29 (leap year EOM).""" + query = WOQLQuery().date_duration(dat("2020-01-31"), "v:e", dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["e"]["@value"] == "2020-02-29" + + def test_jan31_plus_1m_non_leap(self): + """Jan 31 + P1M = Feb 28 (non-leap year EOM).""" + query = WOQLQuery().date_duration(dat("2021-01-31"), "v:e", dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["e"]["@value"] == "2021-02-28" + + def test_feb29_plus_1m_eom_preservation(self): + """Feb 29 + P1M = Mar 31 (EOM preservation).""" + query = WOQLQuery().date_duration(dat("2020-02-29"), "v:e", dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["e"]["@value"] == "2020-03-31" + + def test_apr30_plus_1m_eom_preservation(self): + """Apr 30 + P1M = May 31 (EOM preservation).""" + query = WOQLQuery().date_duration(dat("2020-04-30"), "v:e", dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["e"]["@value"] == "2020-05-31" + + def test_dec31_plus_1m_year_boundary(self): + """Dec 31 + P1M = Jan 31 next year.""" + query = WOQLQuery().date_duration(dat("2020-12-31"), "v:e", dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["e"]["@value"] == "2021-01-31" + + +class TestDateDurationSubtraction: + """Duration + End → Start (EOM-aware).""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_date_dur_sub" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_mar31_minus_1m_leap(self): + """Mar 31 - P1M = Feb 29 (leap year).""" + query = WOQLQuery().date_duration("v:s", dat("2020-03-31"), dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["s"]["@value"] == "2020-02-29" + + def test_mar31_minus_1m_non_leap(self): + """Mar 31 - P1M = Feb 28 (non-leap year).""" + query = WOQLQuery().date_duration("v:s", dat("2021-03-31"), dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["s"]["@value"] == "2021-02-28" + + def test_jan31_minus_1m_year_boundary(self): + """Jan 31 - P1M = Dec 31 previous year.""" + query = WOQLQuery().date_duration("v:s", dat("2021-01-31"), dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["s"]["@value"] == "2020-12-31" + + +class TestDateDurationEOMReversibility: + """EOM reversibility: add then subtract returns original.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_date_dur_eom" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_feb29_minus_1m_equals_jan31(self): + """Feb 29 - P1M = Jan 31 (reverse of Jan 31 + P1M = Feb 29).""" + query = WOQLQuery().date_duration("v:s", dat("2020-02-29"), dur("P1M")) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["s"]["@value"] == "2020-01-31" + + +class TestDateDurationValidation: + """All three ground — validation mode.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_date_dur_val" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_consistent_succeeds(self): + """Consistent start+end+duration returns one binding.""" + query = WOQLQuery().date_duration( + dat("2024-01-01"), dat("2024-04-01"), dur("P91D") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + + def test_inconsistent_fails(self): + """Inconsistent start+end+duration returns zero bindings.""" + query = WOQLQuery().date_duration( + dat("2024-01-01"), dat("2024-04-01"), dur("P90D") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 0 diff --git a/terminusdb_client/tests/integration_tests/test_woql_interval_relation_typed.py b/terminusdb_client/tests/integration_tests/test_woql_interval_relation_typed.py new file mode 100644 index 00000000..e04d6c0d --- /dev/null +++ b/terminusdb_client/tests/integration_tests/test_woql_interval_relation_typed.py @@ -0,0 +1,150 @@ +""" +Integration tests for WOQL IntervalRelationTyped predicate. + +Tests Allen's Interval Algebra on xdd:dateTimeInterval values: +- Validation mode (relation ground) +- Classification mode (relation as variable) +- dateTime interval support +""" + +import pytest + +from terminusdb_client import Client +from terminusdb_client.woqlquery.woql_query import WOQLQuery + +test_user_agent = "terminusdb-client-python-tests" + + +def iv(v): + """Build an xdd:dateTimeInterval typed literal.""" + return {"@type": "xdd:dateTimeInterval", "@value": v} + + +def rel(v): + """Build an xsd:string typed literal for the relation name.""" + return {"@type": "xsd:string", "@value": v} + + +class TestIntervalRelationTypedValidation: + """Validation mode: relation is ground.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_irt_validate" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_meets(self): + """Q1 meets Q2.""" + query = WOQLQuery().interval_relation_typed( + rel("meets"), iv("2024-01-01/2024-04-01"), iv("2024-04-01/2024-07-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + + def test_meets_rejected_with_gap(self): + """Gap between intervals fails meets.""" + query = WOQLQuery().interval_relation_typed( + rel("meets"), iv("2024-01-01/2024-04-01"), iv("2024-05-01/2024-07-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 0 + + def test_before(self): + """Q1 before Q3.""" + query = WOQLQuery().interval_relation_typed( + rel("before"), iv("2024-01-01/2024-03-01"), iv("2024-06-01/2024-09-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + + def test_during(self): + """Sub-interval during year.""" + query = WOQLQuery().interval_relation_typed( + rel("during"), iv("2024-03-01/2024-06-01"), iv("2024-01-01/2024-12-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + + def test_equals(self): + """Same interval.""" + query = WOQLQuery().interval_relation_typed( + rel("equals"), iv("2024-01-01/2024-06-01"), iv("2024-01-01/2024-06-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + + def test_contains(self): + """FY contains Q2.""" + query = WOQLQuery().interval_relation_typed( + rel("contains"), iv("2024-01-01/2025-01-01"), iv("2024-04-01/2024-07-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + + +class TestIntervalRelationTypedClassification: + """Classification mode: relation is a variable.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_irt_classify" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_classifies_meets(self): + """Adjacent intervals classified as meets.""" + query = WOQLQuery().interval_relation_typed( + "v:rel", iv("2024-01-01/2024-04-01"), iv("2024-04-01/2024-07-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["rel"]["@value"] == "meets" + + def test_classifies_before(self): + """Non-overlapping intervals classified as before.""" + query = WOQLQuery().interval_relation_typed( + "v:rel", iv("2024-01-01/2024-03-01"), iv("2024-06-01/2024-09-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["rel"]["@value"] == "before" + + def test_classifies_during(self): + """Nested interval classified as during.""" + query = WOQLQuery().interval_relation_typed( + "v:rel", iv("2024-03-01/2024-06-01"), iv("2024-01-01/2024-12-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["rel"]["@value"] == "during" + + def test_classifies_equals(self): + """Identical intervals classified as equals.""" + query = WOQLQuery().interval_relation_typed( + "v:rel", iv("2024-01-01/2024-06-01"), iv("2024-01-01/2024-06-01") + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["rel"]["@value"] == "equals" + + def test_classifies_datetime_meets(self): + """dateTime intervals classified as meets.""" + query = WOQLQuery().interval_relation_typed( + "v:rel", + iv("2024-01-01T08:00:00Z/2024-01-01T12:00:00Z"), + iv("2024-01-01T12:00:00Z/2024-01-01T17:00:00Z"), + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["rel"]["@value"] == "meets" diff --git a/terminusdb_client/tests/test_woql_date_duration.py b/terminusdb_client/tests/test_woql_date_duration.py new file mode 100644 index 00000000..6dbf010b --- /dev/null +++ b/terminusdb_client/tests/test_woql_date_duration.py @@ -0,0 +1,84 @@ +"""Unit tests for WOQLQuery.date_duration JSON serialization.""" + +from terminusdb_client.woqlquery.woql_query import WOQLQuery + + +class TestDateDurationSerialization: + """Tests that date_duration produces the correct WOQL JSON AST.""" + + def test_all_variables(self): + """All three arguments as variables.""" + query = WOQLQuery().date_duration("v:s", "v:e", "v:d") + expected = { + "@type": "DateDuration", + "start": {"@type": "Value", "variable": "s"}, + "end": {"@type": "Value", "variable": "e"}, + "duration": {"@type": "Value", "variable": "d"}, + } + assert query.to_dict() == expected + + def test_start_and_end_ground_duration_variable(self): + """Start and end as typed literals, duration as variable.""" + query = WOQLQuery().date_duration( + {"@type": "xsd:date", "@value": "2024-01-01"}, + {"@type": "xsd:date", "@value": "2024-04-01"}, + "v:d", + ) + result = query.to_dict() + assert result["@type"] == "DateDuration" + assert result["duration"] == {"@type": "Value", "variable": "d"} + assert result["start"]["@type"] == "Value" + assert result["end"]["@type"] == "Value" + + def test_start_and_duration_ground_end_variable(self): + """Start and duration ground, end as variable.""" + query = WOQLQuery().date_duration( + {"@type": "xsd:date", "@value": "2020-01-31"}, + "v:e", + {"@type": "xsd:duration", "@value": "P1M"}, + ) + result = query.to_dict() + assert result["@type"] == "DateDuration" + assert result["end"] == {"@type": "Value", "variable": "e"} + + def test_end_and_duration_ground_start_variable(self): + """End and duration ground, start as variable.""" + query = WOQLQuery().date_duration( + "v:s", + {"@type": "xsd:date", "@value": "2020-03-31"}, + {"@type": "xsd:duration", "@value": "P1M"}, + ) + result = query.to_dict() + assert result["@type"] == "DateDuration" + assert result["start"] == {"@type": "Value", "variable": "s"} + + def test_all_ground(self): + """All three as typed literals (validation mode).""" + query = WOQLQuery().date_duration( + {"@type": "xsd:date", "@value": "2024-01-01"}, + {"@type": "xsd:date", "@value": "2024-04-01"}, + {"@type": "xsd:duration", "@value": "P91D"}, + ) + result = query.to_dict() + assert result["@type"] == "DateDuration" + assert result["start"]["@type"] == "Value" + assert result["end"]["@type"] == "Value" + assert result["duration"]["@type"] == "Value" + + def test_raises_on_none_start(self): + """Raises ValueError when start is None.""" + import pytest + with pytest.raises(ValueError, match="DateDuration takes three parameters"): + WOQLQuery().date_duration(None, "v:e", "v:d") + + def test_raises_on_none_end(self): + """Raises ValueError when end is None.""" + import pytest + with pytest.raises(ValueError, match="DateDuration takes three parameters"): + WOQLQuery().date_duration("v:s", None, "v:d") + + def test_raises_on_none_duration(self): + """Raises ValueError when duration is None.""" + import pytest + with pytest.raises(ValueError, match="DateDuration takes three parameters"): + WOQLQuery().date_duration("v:s", "v:e", None) diff --git a/terminusdb_client/tests/test_woql_interval_relation_typed.py b/terminusdb_client/tests/test_woql_interval_relation_typed.py new file mode 100644 index 00000000..56744f1d --- /dev/null +++ b/terminusdb_client/tests/test_woql_interval_relation_typed.py @@ -0,0 +1,59 @@ +"""Unit tests for WOQLQuery.interval_relation_typed JSON serialization.""" + +from terminusdb_client.woqlquery.woql_query import WOQLQuery + + +class TestIntervalRelationTypedSerialization: + """Tests that interval_relation_typed produces the correct WOQL JSON AST.""" + + def test_all_variables(self): + """All three arguments as variables.""" + query = WOQLQuery().interval_relation_typed("v:rel", "v:x", "v:y") + expected = { + "@type": "IntervalRelationTyped", + "relation": {"@type": "Value", "variable": "rel"}, + "x": {"@type": "Value", "variable": "x"}, + "y": {"@type": "Value", "variable": "y"}, + } + assert query.to_dict() == expected + + def test_relation_ground_intervals_variable(self): + """Relation as typed literal, intervals as variables.""" + query = WOQLQuery().interval_relation_typed( + {"@type": "xsd:string", "@value": "meets"}, "v:x", "v:y" + ) + result = query.to_dict() + assert result["@type"] == "IntervalRelationTyped" + assert result["x"] == {"@type": "Value", "variable": "x"} + assert result["y"] == {"@type": "Value", "variable": "y"} + + def test_all_ground(self): + """All three as typed literals (validation mode).""" + query = WOQLQuery().interval_relation_typed( + {"@type": "xsd:string", "@value": "meets"}, + {"@type": "xdd:dateTimeInterval", "@value": "2024-01-01/2024-04-01"}, + {"@type": "xdd:dateTimeInterval", "@value": "2024-04-01/2024-07-01"}, + ) + result = query.to_dict() + assert result["@type"] == "IntervalRelationTyped" + assert result["relation"]["@type"] == "Value" + assert result["x"]["@type"] == "Value" + assert result["y"]["@type"] == "Value" + + def test_raises_on_none_relation(self): + """Raises ValueError when relation is None.""" + import pytest + with pytest.raises(ValueError, match="IntervalRelationTyped takes three parameters"): + WOQLQuery().interval_relation_typed(None, "v:x", "v:y") + + def test_raises_on_none_x(self): + """Raises ValueError when x is None.""" + import pytest + with pytest.raises(ValueError, match="IntervalRelationTyped takes three parameters"): + WOQLQuery().interval_relation_typed("v:rel", None, "v:y") + + def test_raises_on_none_y(self): + """Raises ValueError when y is None.""" + import pytest + with pytest.raises(ValueError, match="IntervalRelationTyped takes three parameters"): + WOQLQuery().interval_relation_typed("v:rel", "v:x", None) From 97a79b7c3f8940398d86f6a5a9d593b20df8f625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sun, 22 Feb 2026 22:03:26 +0100 Subject: [PATCH 15/19] Range min/max query on list --- .../test_woql_range_min_max.py | 113 ++++++++++++++++++ .../tests/test_woql_range_min_max.py | 53 ++++++++ terminusdb_client/woqlquery/woql_query.py | 54 +++++++++ 3 files changed, 220 insertions(+) create mode 100644 terminusdb_client/tests/integration_tests/test_woql_range_min_max.py create mode 100644 terminusdb_client/tests/test_woql_range_min_max.py diff --git a/terminusdb_client/tests/integration_tests/test_woql_range_min_max.py b/terminusdb_client/tests/integration_tests/test_woql_range_min_max.py new file mode 100644 index 00000000..fc8295b4 --- /dev/null +++ b/terminusdb_client/tests/integration_tests/test_woql_range_min_max.py @@ -0,0 +1,113 @@ +""" +Integration tests for WOQL RangeMin and RangeMax predicates. + +Tests finding minimum and maximum values in lists: +- Integer lists +- Date lists +- Single element +- Empty list +- Equal elements +""" + +import pytest + +from terminusdb_client import Client +from terminusdb_client.woqlquery.woql_query import WOQLQuery + +test_user_agent = "terminusdb-client-python-tests" + + +def intv(v): + """Build an xsd:integer typed literal.""" + return {"@type": "xsd:integer", "@value": v} + + +def datv(v): + """Build an xsd:date typed literal.""" + return {"@type": "xsd:date", "@value": v} + + +class TestRangeMin: + """Integration tests for RangeMin.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_range_min" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_min_integers(self): + """Minimum of [7, 2, 9, 1, 5] is 1.""" + query = WOQLQuery().range_min( + [intv(7), intv(2), intv(9), intv(1), intv(5)], "v:m" + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["m"]["@value"] == 1 + + def test_min_single_element(self): + """Single element list returns that element.""" + query = WOQLQuery().range_min([intv(42)], "v:m") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["m"]["@value"] == 42 + + def test_min_empty_list(self): + """Empty list yields no bindings.""" + query = WOQLQuery().range_min([], "v:m") + result = self.client.query(query) + assert len(result["bindings"]) == 0 + + def test_min_dates(self): + """Minimum of dates.""" + query = WOQLQuery().range_min( + [datv("2024-06-15"), datv("2024-01-01"), datv("2024-03-01")], "v:m" + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["m"]["@value"] == "2024-01-01" + + def test_min_equal_elements(self): + """All equal elements returns that element.""" + query = WOQLQuery().range_min([intv(3), intv(3), intv(3)], "v:m") + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["m"]["@value"] == 3 + + +class TestRangeMax: + """Integration tests for RangeMax.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self, docker_url): + self.client = Client(docker_url, user_agent=test_user_agent) + self.client.connect() + self.db_name = "test_range_max" + if self.db_name in self.client.list_databases(): + self.client.delete_database(self.db_name) + self.client.create_database(self.db_name) + yield + self.client.delete_database(self.db_name) + + def test_max_integers(self): + """Maximum of [7, 2, 9, 1, 5] is 9.""" + query = WOQLQuery().range_max( + [intv(7), intv(2), intv(9), intv(1), intv(5)], "v:m" + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["m"]["@value"] == 9 + + def test_max_dates(self): + """Maximum of dates.""" + query = WOQLQuery().range_max( + [datv("2024-06-15"), datv("2024-01-01"), datv("2024-03-01")], "v:m" + ) + result = self.client.query(query) + assert len(result["bindings"]) == 1 + assert result["bindings"][0]["m"]["@value"] == "2024-06-15" diff --git a/terminusdb_client/tests/test_woql_range_min_max.py b/terminusdb_client/tests/test_woql_range_min_max.py new file mode 100644 index 00000000..bb146e30 --- /dev/null +++ b/terminusdb_client/tests/test_woql_range_min_max.py @@ -0,0 +1,53 @@ +"""Unit tests for WOQLQuery.range_min and range_max JSON serialization.""" + +import pytest + +from terminusdb_client.woqlquery.woql_query import WOQLQuery + + +class TestRangeMinSerialization: + """Tests that range_min produces the correct WOQL JSON AST.""" + + def test_variable_result(self): + """List and variable result.""" + query = WOQLQuery().range_min("v:list", "v:m") + expected = { + "@type": "RangeMin", + "list": {"@type": "Value", "variable": "list"}, + "result": {"@type": "Value", "variable": "m"}, + } + assert query.to_dict() == expected + + def test_raises_on_none_list(self): + """Raises ValueError when list is None.""" + with pytest.raises(ValueError, match="RangeMin takes two parameters"): + WOQLQuery().range_min(None, "v:m") + + def test_raises_on_none_result(self): + """Raises ValueError when result is None.""" + with pytest.raises(ValueError, match="RangeMin takes two parameters"): + WOQLQuery().range_min("v:list", None) + + +class TestRangeMaxSerialization: + """Tests that range_max produces the correct WOQL JSON AST.""" + + def test_variable_result(self): + """List and variable result.""" + query = WOQLQuery().range_max("v:list", "v:m") + expected = { + "@type": "RangeMax", + "list": {"@type": "Value", "variable": "list"}, + "result": {"@type": "Value", "variable": "m"}, + } + assert query.to_dict() == expected + + def test_raises_on_none_list(self): + """Raises ValueError when list is None.""" + with pytest.raises(ValueError, match="RangeMax takes two parameters"): + WOQLQuery().range_max(None, "v:m") + + def test_raises_on_none_result(self): + """Raises ValueError when result is None.""" + with pytest.raises(ValueError, match="RangeMax takes two parameters"): + WOQLQuery().range_max("v:list", None) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 5968e2b8..e8d8a8da 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2470,6 +2470,60 @@ def interval_relation_typed(self, relation, x, y): self._cursor["y"] = self._clean_object(y) return self + def range_min(self, input_list, result): + """Find the minimum value in a list using the standard ordering. + + Works with any comparable types: numbers, dates, strings. + Empty list produces no bindings. + + Parameters + ---------- + input_list : list or str or dict + the list of values to search + result : str or dict + variable or value for the minimum + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if input_list is None or result is None: + raise ValueError("RangeMin takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "RangeMin" + self._cursor["list"] = self._clean_object(input_list) + self._cursor["result"] = self._clean_object(result) + return self + + def range_max(self, input_list, result): + """Find the maximum value in a list using the standard ordering. + + Works with any comparable types: numbers, dates, strings. + Empty list produces no bindings. + + Parameters + ---------- + input_list : list or str or dict + the list of values to search + result : str or dict + variable or value for the maximum + + Returns + ------- + WOQLQuery object + query object that can be chained and/or execute + """ + if input_list is None or result is None: + raise ValueError("RangeMax takes two parameters") + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "RangeMax" + self._cursor["list"] = self._clean_object(input_list) + self._cursor["result"] = self._clean_object(result) + return self + def interval(self, start, end, interval_val): """Constructs or deconstructs a half-open xdd:dateTimeInterval [start, end). From c92734dc3f4f6443e0a7b2ee1b77ab7a6bee4fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Thu, 26 Feb 2026 07:33:50 +0100 Subject: [PATCH 16/19] Whitespace --- .../test_woql_interval_duration.py | 47 +++++++++++-------- .../tests/test_woql_date_duration.py | 3 ++ .../test_woql_interval_relation_typed.py | 15 ++++-- terminusdb_client/woqlquery/woql_query.py | 9 +++- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py b/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py index 2e5af526..839cc237 100644 --- a/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py +++ b/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py @@ -52,28 +52,32 @@ def setup_teardown(self, docker_url): def test_construct_from_datetime_endpoints(self): """Construct interval from two xsd:dateTime values.""" - query = WOQLQuery().interval(dtm("2025-01-01T09:00:00Z"), - dtm("2025-01-01T17:30:00Z"), - "v:iv") + query = WOQLQuery().interval( + dtm("2025-01-01T09:00:00Z"), dtm("2025-01-01T17:30:00Z"), "v:iv" + ) result = self.client.query(query) assert len(result["bindings"]) == 1 - assert result["bindings"][0]["iv"]["@value"] == \ - "2025-01-01T09:00:00Z/2025-01-01T17:30:00Z" + assert ( + result["bindings"][0]["iv"]["@value"] + == "2025-01-01T09:00:00Z/2025-01-01T17:30:00Z" + ) def test_construct_mixed_date_datetime(self): """Construct interval with date start and dateTime end.""" - query = WOQLQuery().interval(dat("2025-01-01"), - dtm("2025-04-01T12:00:00Z"), - "v:iv") + query = WOQLQuery().interval( + dat("2025-01-01"), dtm("2025-04-01T12:00:00Z"), "v:iv" + ) result = self.client.query(query) assert len(result["bindings"]) == 1 - assert result["bindings"][0]["iv"]["@value"] == \ - "2025-01-01/2025-04-01T12:00:00Z" + assert ( + result["bindings"][0]["iv"]["@value"] == "2025-01-01/2025-04-01T12:00:00Z" + ) def test_deconstruct_datetime_interval(self): """Deconstruct dateTime interval preserves types.""" - query = WOQLQuery().interval("v:s", "v:e", - dti("2025-01-01T09:00:00Z/2025-04-01T17:30:00Z")) + query = WOQLQuery().interval( + "v:s", "v:e", dti("2025-01-01T09:00:00Z/2025-04-01T17:30:00Z") + ) result = self.client.query(query) assert len(result["bindings"]) == 1 assert result["bindings"][0]["s"]["@type"] == "xsd:dateTime" @@ -97,7 +101,8 @@ def setup_teardown(self, docker_url): def test_extract_start_and_duration_from_date_interval(self): """Extract start + P90D duration from a 90-day interval.""" query = WOQLQuery().interval_start_duration( - "v:s", "v:d", dti("2025-01-01/2025-04-01")) + "v:s", "v:d", dti("2025-01-01/2025-04-01") + ) result = self.client.query(query) assert len(result["bindings"]) == 1 assert result["bindings"][0]["s"]["@value"] == "2025-01-01" @@ -106,8 +111,8 @@ def test_extract_start_and_duration_from_date_interval(self): def test_extract_sub_day_duration_from_datetime_interval(self): """Extract PT8H30M duration from a sub-day dateTime interval.""" query = WOQLQuery().interval_start_duration( - "v:s", "v:d", - dti("2025-01-01T09:00:00Z/2025-01-01T17:30:00Z")) + "v:s", "v:d", dti("2025-01-01T09:00:00Z/2025-01-01T17:30:00Z") + ) result = self.client.query(query) assert len(result["bindings"]) == 1 assert result["bindings"][0]["s"]["@type"] == "xsd:dateTime" @@ -116,7 +121,8 @@ def test_extract_sub_day_duration_from_datetime_interval(self): def test_construct_interval_from_start_and_duration(self): """Construct [Jan 1, Apr 1) from start date + P90D.""" query = WOQLQuery().interval_start_duration( - dat("2025-01-01"), dur("P90D"), "v:iv") + dat("2025-01-01"), dur("P90D"), "v:iv" + ) result = self.client.query(query) assert len(result["bindings"]) == 1 assert result["bindings"][0]["iv"]["@value"] == "2025-01-01/2025-04-01" @@ -124,7 +130,8 @@ def test_construct_interval_from_start_and_duration(self): def test_construct_full_year_interval(self): """Construct a 365-day interval from start + P365D.""" query = WOQLQuery().interval_start_duration( - dat("2025-01-01"), dur("P365D"), "v:iv") + dat("2025-01-01"), dur("P365D"), "v:iv" + ) result = self.client.query(query) assert len(result["bindings"]) == 1 assert result["bindings"][0]["iv"]["@value"] == "2025-01-01/2026-01-01" @@ -147,7 +154,8 @@ def setup_teardown(self, docker_url): def test_extract_duration_and_end_from_interval(self): """Extract P90D + end date from a 90-day interval.""" query = WOQLQuery().interval_duration_end( - "v:d", "v:e", dti("2025-01-01/2025-04-01")) + "v:d", "v:e", dti("2025-01-01/2025-04-01") + ) result = self.client.query(query) assert len(result["bindings"]) == 1 assert result["bindings"][0]["e"]["@value"] == "2025-04-01" @@ -156,7 +164,8 @@ def test_extract_duration_and_end_from_interval(self): def test_construct_interval_from_duration_and_end(self): """Construct [Jan 1, Apr 1) from P90D + end date.""" query = WOQLQuery().interval_duration_end( - dur("P90D"), dat("2025-04-01"), "v:iv") + dur("P90D"), dat("2025-04-01"), "v:iv" + ) result = self.client.query(query) assert len(result["bindings"]) == 1 assert result["bindings"][0]["iv"]["@value"] == "2025-01-01/2025-04-01" diff --git a/terminusdb_client/tests/test_woql_date_duration.py b/terminusdb_client/tests/test_woql_date_duration.py index 6dbf010b..de79f899 100644 --- a/terminusdb_client/tests/test_woql_date_duration.py +++ b/terminusdb_client/tests/test_woql_date_duration.py @@ -68,17 +68,20 @@ def test_all_ground(self): def test_raises_on_none_start(self): """Raises ValueError when start is None.""" import pytest + with pytest.raises(ValueError, match="DateDuration takes three parameters"): WOQLQuery().date_duration(None, "v:e", "v:d") def test_raises_on_none_end(self): """Raises ValueError when end is None.""" import pytest + with pytest.raises(ValueError, match="DateDuration takes three parameters"): WOQLQuery().date_duration("v:s", None, "v:d") def test_raises_on_none_duration(self): """Raises ValueError when duration is None.""" import pytest + with pytest.raises(ValueError, match="DateDuration takes three parameters"): WOQLQuery().date_duration("v:s", "v:e", None) diff --git a/terminusdb_client/tests/test_woql_interval_relation_typed.py b/terminusdb_client/tests/test_woql_interval_relation_typed.py index 56744f1d..8daf0f77 100644 --- a/terminusdb_client/tests/test_woql_interval_relation_typed.py +++ b/terminusdb_client/tests/test_woql_interval_relation_typed.py @@ -43,17 +43,26 @@ def test_all_ground(self): def test_raises_on_none_relation(self): """Raises ValueError when relation is None.""" import pytest - with pytest.raises(ValueError, match="IntervalRelationTyped takes three parameters"): + + with pytest.raises( + ValueError, match="IntervalRelationTyped takes three parameters" + ): WOQLQuery().interval_relation_typed(None, "v:x", "v:y") def test_raises_on_none_x(self): """Raises ValueError when x is None.""" import pytest - with pytest.raises(ValueError, match="IntervalRelationTyped takes three parameters"): + + with pytest.raises( + ValueError, match="IntervalRelationTyped takes three parameters" + ): WOQLQuery().interval_relation_typed("v:rel", None, "v:y") def test_raises_on_none_y(self): """Raises ValueError when y is None.""" import pytest - with pytest.raises(ValueError, match="IntervalRelationTyped takes three parameters"): + + with pytest.raises( + ValueError, match="IntervalRelationTyped takes three parameters" + ): WOQLQuery().interval_relation_typed("v:rel", "v:x", None) diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index e8d8a8da..e49699bf 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2425,8 +2425,13 @@ def interval_relation(self, relation, x_start, x_end, y_start, y_end): WOQLQuery object query object that can be chained and/or execute """ - if (relation is None or x_start is None or x_end is None - or y_start is None or y_end is None): + if ( + relation is None + or x_start is None + or x_end is None + or y_start is None + or y_end is None + ): raise ValueError("IntervalRelation takes five parameters") if self._cursor.get("@type"): self._wrap_cursor_with_and() From 789e655b5a1e6f781083412b4e3ef238892058e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Thu, 26 Feb 2026 07:34:40 +0100 Subject: [PATCH 17/19] Fix database test concurrency issue --- pyproject.toml | 2 +- .../tests/integration_tests/conftest.py | 4 +- .../tests/integration_tests/test_client.py | 59 +++++++++++++++++++ .../tests/integration_tests/test_schema.py | 27 +++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8d1d37b..54c9b2e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "terminusdb-client" -version = "12.0.3" +version = "12.0.5" description = "Python client for Terminus DB" authors = ["TerminusDB group"] license = "Apache Software License" diff --git a/terminusdb_client/tests/integration_tests/conftest.py b/terminusdb_client/tests/integration_tests/conftest.py index 75bf84ce..d1ab0d00 100644 --- a/terminusdb_client/tests/integration_tests/conftest.py +++ b/terminusdb_client/tests/integration_tests/conftest.py @@ -145,9 +145,7 @@ def docker_url(pytestconfig): """ # Check if a server is already running (port 6363) if is_local_server_running(): - print( - "\n✓ Using existing TerminusDB server at http://127.0.0.1:6363" - ) + print("\n✓ Using existing TerminusDB server at http://127.0.0.1:6363") yield "http://127.0.0.1:6363" return # Don't clean up - server was already running diff --git a/terminusdb_client/tests/integration_tests/test_client.py b/terminusdb_client/tests/integration_tests/test_client.py index 57fbb499..775049c0 100644 --- a/terminusdb_client/tests/integration_tests/test_client.py +++ b/terminusdb_client/tests/integration_tests/test_client.py @@ -12,6 +12,65 @@ test_user_agent = "terminusdb-client-python-tests" +_CLIENT_TEST_DBS = ["test_diff_ops"] +_CLIENT_TEST_ORGS = ["testOrg235091"] + + +@pytest.fixture(scope="module", autouse=True) +def cleanup_client_resources(docker_url): + """Delete stale databases and organizations before the module and clean up after.""" + client = Client(docker_url, user_agent=test_user_agent) + client.connect() + + def _cleanup(): + for db in _CLIENT_TEST_DBS: + try: + client.delete_database(db) + except Exception: + pass + for org in _CLIENT_TEST_ORGS: + # Ensure admin has access so we can list and delete databases + try: + client.change_capabilities( + { + "operation": "grant", + "scope": f"Organization/{org}", + "user": "User/admin", + "roles": ["Role/admin"], + } + ) + except Exception: + pass + try: + dbs = client.get_organization_user_databases(org=org, username="admin") + for db in dbs: + try: + client.delete_database(db["name"], team=org) + except Exception: + pass + except Exception: + pass + # Revoke capabilities before deleting org + try: + client.change_capabilities( + { + "operation": "revoke", + "scope": f"Organization/{org}", + "user": "User/admin", + "roles": ["Role/admin"], + } + ) + except Exception: + pass + try: + client.delete_organization(org) + except Exception: + pass + + _cleanup() + yield + _cleanup() + def test_not_ok(): client = Client("http://localhost:6363") diff --git a/terminusdb_client/tests/integration_tests/test_schema.py b/terminusdb_client/tests/integration_tests/test_schema.py index 8cea3ffa..37b929bb 100644 --- a/terminusdb_client/tests/integration_tests/test_schema.py +++ b/terminusdb_client/tests/integration_tests/test_schema.py @@ -8,6 +8,33 @@ test_user_agent = "terminusdb-client-python-tests" +_SCHEMA_TEST_DBS = [ + "test_docapi", + "test_docapi2", + "test_datetime", + "test_compress_data", + "test_repeated_load", + "test_repeated_load_fails", +] + + +@pytest.fixture(scope="module", autouse=True) +def cleanup_schema_databases(docker_url): + """Delete stale databases before the module and clean up after.""" + client = Client(docker_url, user_agent=test_user_agent) + client.connect() + for db in _SCHEMA_TEST_DBS: + try: + client.delete_database(db) + except Exception: + pass + yield + for db in _SCHEMA_TEST_DBS: + try: + client.delete_database(db) + except Exception: + pass + def test_create_schema(docker_url, test_schema): my_schema = test_schema From 8b3113fb2c828d0345d4e6692eff5bba605c3698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Thu, 26 Feb 2026 08:22:50 +0100 Subject: [PATCH 18/19] Make the tests run correctly with docker --- terminusdb_client/tests/integration_tests/conftest.py | 10 ++++++---- .../tests/integration_tests/test_conftest.py | 8 ++++---- terminusdb_client/woqlquery/woql_query.py | 2 ++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/terminusdb_client/tests/integration_tests/conftest.py b/terminusdb_client/tests/integration_tests/conftest.py index 93eccb45..0bf21d68 100644 --- a/terminusdb_client/tests/integration_tests/conftest.py +++ b/terminusdb_client/tests/integration_tests/conftest.py @@ -12,8 +12,9 @@ def is_local_server_running(): """Check if local TerminusDB server is running at http://127.0.0.1:6363""" try: - response = requests.get("http://127.0.0.1:6363/api/ok", timeout=2) - return response.status_code == 200 + requests.get("http://127.0.0.1:6363/api/ok", timeout=2) + # Any HTTP response means server is running + return True except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): return False @@ -21,8 +22,9 @@ def is_local_server_running(): def is_docker_server_running(): """Check if Docker TerminusDB server is already running at http://127.0.0.1:6363""" try: - response = requests.get("http://127.0.0.1:6363/api/ok", timeout=2) - return response.status_code == 200 + requests.get("http://127.0.0.1:6363/api/ok", timeout=2) + # Any HTTP response means server is running + return True except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): return False diff --git a/terminusdb_client/tests/integration_tests/test_conftest.py b/terminusdb_client/tests/integration_tests/test_conftest.py index 66875c0c..8fb848f2 100644 --- a/terminusdb_client/tests/integration_tests/test_conftest.py +++ b/terminusdb_client/tests/integration_tests/test_conftest.py @@ -23,12 +23,12 @@ def test_local_server_running_any_response(self, mock_get): @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") def test_local_server_running_not_200(self, mock_get): - """Test local server detection returns False for non-200 status""" + """Test local server detection returns True for non-200 status (server is running)""" mock_response = Mock() mock_response.status_code = 401 mock_get.return_value = mock_response - assert is_local_server_running() is False + assert is_local_server_running() is True @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") def test_local_server_not_running_connection_error(self, mock_get): @@ -54,12 +54,12 @@ def test_docker_server_running_any_response(self, mock_get): @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") def test_docker_server_running_not_200(self, mock_get): - """Test Docker server detection returns False for non-200 status""" + """Test Docker server detection returns True for non-200 status (server is running)""" mock_response = Mock() mock_response.status_code = 401 mock_get.return_value = mock_response - assert is_docker_server_running() is False + assert is_docker_server_running() is True @patch("terminusdb_client.tests.integration_tests.conftest.requests.get") def test_docker_server_not_running(self, mock_get): diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 63691fd5..8aed3e67 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2417,6 +2417,8 @@ def greater(self, left, right): WOQLQuery object query object that can be chained and/or execute """ + if left and left == "args": + return ["left", "right"] if left is None or right is None: raise ValueError("Greater takes two parameters") if self._cursor.get("@type"): From 7264c1afab0d40a3560fcf6d173c787c8f2c22d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Thu, 26 Feb 2026 09:50:34 +0100 Subject: [PATCH 19/19] Bump version from 12.0.4 to 12.0.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5d1cd0db..19baa76b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "terminusdb" -version = "12.0.4" +version = "12.0.5" description = "TerminusDB Python client" authors = ["TerminusDB group", "DFRNT AB"] license = "Apache Software License"