diff --git a/pyproject.toml b/pyproject.toml index c4ec5d95..19baa76b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "terminusdb" -version = "12.0.4" -description = "Terminus DB Python client" +version = "12.0.5" +description = "TerminusDB Python client" authors = ["TerminusDB group", "DFRNT AB"] license = "Apache Software License" readme = "README.md" diff --git a/terminusdb_client/tests/integration_tests/conftest.py b/terminusdb_client/tests/integration_tests/conftest.py index b1982145..0bf21d68 100644 --- a/terminusdb_client/tests/integration_tests/conftest.py +++ b/terminusdb_client/tests/integration_tests/conftest.py @@ -12,18 +12,17 @@ def is_local_server_running(): """Check if local TerminusDB server is running at http://127.0.0.1:6363""" try: - requests.get("http://127.0.0.1:6363/api/", timeout=2) - # Any HTTP response means server is running (200, 302, 401, 404, 500, etc.) - # We only care that we got a response, not what the response is + 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 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: - requests.get("http://127.0.0.1:6366/api/", timeout=2) + 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): @@ -143,33 +142,17 @@ 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" - ) + 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 - # 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_client.py b/terminusdb_client/tests/integration_tests/test_client.py index 6f484613..8729ac8a 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_conftest.py b/terminusdb_client/tests/integration_tests/test_conftest.py index 576ee2c9..8fb848f2 100644 --- a/terminusdb_client/tests/integration_tests/test_conftest.py +++ b/terminusdb_client/tests/integration_tests/test_conftest.py @@ -19,11 +19,11 @@ def test_local_server_running_any_response(self, mock_get): mock_get.return_value = Mock() assert is_local_server_running() is True - mock_get.assert_called_once_with("http://127.0.0.1:6363/api/", 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_401(self, mock_get): - """Test local server detection returns True for HTTP 401 (unauthorized)""" + def test_local_server_running_not_200(self, mock_get): + """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 @@ -50,11 +50,11 @@ def test_docker_server_running_any_response(self, mock_get): mock_get.return_value = Mock() assert is_docker_server_running() is True - mock_get.assert_called_once_with("http://127.0.0.1:6366/api/", 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_401(self, mock_get): - """Test Docker server detection returns True for HTTP 401 (unauthorized)""" + def test_docker_server_running_not_200(self, mock_get): + """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 diff --git a/terminusdb_client/tests/integration_tests/test_schema.py b/terminusdb_client/tests/integration_tests/test_schema.py index 352372fc..235d2efa 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 + # Static prefix for test databases - unique enough to avoid clashes with real databases TEST_DB_PREFIX = "pyclient_test_xk7q_" 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_duration.py b/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py new file mode 100644 index 00000000..839cc237 --- /dev/null +++ b/terminusdb_client/tests/integration_tests/test_woql_interval_duration.py @@ -0,0 +1,202 @@ +""" +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" 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/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_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={ 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..de79f899 --- /dev/null +++ b/terminusdb_client/tests/test_woql_date_duration.py @@ -0,0 +1,87 @@ +"""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..8daf0f77 --- /dev/null +++ b/terminusdb_client/tests/test_woql_interval_relation_typed.py @@ -0,0 +1,68 @@ +"""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) 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 f795e3bf..8aed3e67 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -2381,9 +2381,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 @@ -2393,6 +2393,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" @@ -2405,9 +2407,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 @@ -2417,6 +2419,8 @@ def greater(self, left, right): """ 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" @@ -2424,6 +2428,594 @@ 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 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 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 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 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 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 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). + + 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 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. + + 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 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 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