From ef26c64dc6b9c0e55a42c3be0a7b12abd2345bef Mon Sep 17 00:00:00 2001 From: john Date: Sat, 3 May 2025 20:01:50 -0700 Subject: [PATCH 1/3] add the vizql initial support --- samples/vds.py | 72 +++ setup.cfg | 6 + tableauserverclient/server/__init__.py | 2 + .../server/endpoint/__init__.py | 3 +- .../server/endpoint/endpoint.py | 31 +- .../server/endpoint/vizql_endpoint.py | 506 ++++++++++++++++++ tableauserverclient/server/server.py | 2 + 7 files changed, 606 insertions(+), 16 deletions(-) create mode 100644 samples/vds.py create mode 100644 setup.cfg create mode 100644 tableauserverclient/server/endpoint/vizql_endpoint.py diff --git a/samples/vds.py b/samples/vds.py new file mode 100644 index 000000000..724ef189f --- /dev/null +++ b/samples/vds.py @@ -0,0 +1,72 @@ +#### +# Getting Started Part Three of Three +# This script demonstrates all the different types of 'content' a server contains +# +# To make it easy to run, it doesn't take any arguments - you need to edit the code with your info +#### + +from typing import Dict, Any +from rich import print + +import getpass +import tableauserverclient as TSC + + +def main(): + # 1 - replace with your server domain: stop at the slash + server_url = "" + + # 2 - optional - change to false **for testing only** if you get a certificate error + use_ssl = True + + server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) + print(f"Connected to {server.server_info.baseurl}") + + # 3 - replace with your site name exactly as it looks in the url + # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 - replace with your username. + # REMEMBER: if you are using Tableau Online, your username is the entire email address + username = "" + password = "" #getpass.getpass("Enter your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, use a Personal Access Token (PAT) (required by Tableau Cloud) + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + # schema - may be used to understand the schema of the VDS API + print(server.vizql.VizQL_Schema) + # query + query_dict: Dict[str, Any] = { + "fields": [{"fieldCaption": "School Code"}], + "filters": [ + { + "field": {"fieldCaption": "Enrollment (K-12)", "function": "SUM"}, + "filterType": "QUANTITATIVE_NUMERICAL", + "quantitativeFilterType": "MIN", + "min": 500 + } + ] + } + datasource_id = "e4d75fd4-af1c-4fb8-a049-058e3fef57bc" + + # query metadata + vds_metadata = server.vizql.query_vds_metadata( + datasource_id=datasource_id + ) + if vds_metadata: + print(vds_metadata) + + # query data + vds_data = server.vizql.query_vds_data( + query=query_dict, + datasource_id=datasource_id + ) + if vds_data: + print(vds_data) +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..3746a0d1a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[versioneer] +VCS = git +style = pep440-pre +versionfile_source = tableauserverclient/bin/_version.py +versionfile_build = tableauserverclient/bin/_version.py +tag_prefix = v \ No newline at end of file diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 55288fdc9..c937a8a67 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -45,6 +45,7 @@ Views, Webhooks, Workbooks, + VizQL, ) __all__ = [ @@ -91,4 +92,5 @@ "Views", "Webhooks", "Workbooks", + "VizQL", ] diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index b05b9addd..0f115af6a 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -30,7 +30,7 @@ from tableauserverclient.server.endpoint.virtual_connections_endpoint import VirtualConnections from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks - +from tableauserverclient.server.endpoint.vizql_endpoint import VizQL __all__ = [ "Auth", "CustomViews", @@ -66,4 +66,5 @@ "VirtualConnections", "Webhooks", "Workbooks", + "VizQL", ] diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..004cac4a8 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -66,7 +66,7 @@ def set_parameters(http_options, auth_token, content, content_type, parameters) parameters["headers"][CONTENT_TYPE_HEADER] = content_type Endpoint.set_user_agent(parameters) - if content is not None: + if content is not None and content_type != JSON_CONTENT_TYPE: parameters["data"] = content return parameters or {} @@ -86,21 +86,20 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters - def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: + def _blocking_request(self, method, url, parameters={}, content=None, content_type=None) -> Optional[Union["Response", Exception]]: response = None logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") try: - response = method(url, **parameters) + if content and content_type == JSON_CONTENT_TYPE: + response = method(url, json=content, **parameters) + else: + response = method(url, **parameters) logger.debug(f"[{datetime.timestamp()}] Call finished") except Exception as e: logger.debug(f"Error making request to server: {e}") raise e return response - def send_request_while_show_progress_threaded( - self, method, url, parameters={}, request_timeout=None - ) -> Optional[Union["Response", Exception]]: - return self._blocking_request(method, url, parameters) def _make_request( self, @@ -116,17 +115,19 @@ def _make_request( ) logger.debug(f"request method {method.__name__}, url: {url}") - if content: - redacted = helpers.strings.redact_xml(content[:200]) - # this needs to be under a trace or something, it's a LOT - # logger.debug("request content: {}".format(redacted)) # a request can, for stuff like publishing, spin for ages waiting for a response. # we need some user-facing activity so they know it's not dead. - request_timeout = self.parent_srv.http_options.get("timeout") or 0 - server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( - method, url, parameters, request_timeout - ) + + if content and content_type == JSON_CONTENT_TYPE: + server_response: Optional[Union["Response", Exception]] = self._blocking_request( + method, url, parameters, content, content_type + ) + else: + server_response: Optional[Union["Response", Exception]] = self._blocking_request( + method, url, parameters + ) + logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: diff --git a/tableauserverclient/server/endpoint/vizql_endpoint.py b/tableauserverclient/server/endpoint/vizql_endpoint.py new file mode 100644 index 000000000..560a415f5 --- /dev/null +++ b/tableauserverclient/server/endpoint/vizql_endpoint.py @@ -0,0 +1,506 @@ +import json +import logging + +from .endpoint import JSON_CONTENT_TYPE, Endpoint, api +from .exceptions import GraphQLError, InvalidGraphQLQuery + +from tableauserverclient.helpers.logging import logger + +class VizQL(Endpoint): + VizQL_Schema = { + "FieldBase": { + "type": "object", + "description": "Common properties of a Field. A Field represents a column of data in a published datasource", + "required": [ "fieldCaption" ], + "properties": { + "fieldCaption": { + "type": "string", + "description": "Either the name of a specific Field in the data source, or, in the case of a calculation, a user-supplied name for the calculation." + }, + "fieldAlias": { + "type": "string", + "description": "An alternative name to give the field. Will only be used in Object format output." + }, + "maxDecimalPlaces": { + "type": "integer", + "description": "The maximum number of decimal places. Any trailing 0s will be dropped. The maxDecimalPlaces value must be greater or equal to 0." + }, + "sortDirection": { + "$ref": "#/components/schemas/SortDirection" + }, + "sortPriority": { + "type": "integer", + "description": "To enable sorting on a specific Field provide a sortPriority for that field, and that field will be sorted. The sortPriority provides a ranking of how to sort Fields when multiple Fields are being sorted. The highest priority (lowest number) Field is sorted first. If only 1 Field is being sorted, then any value may be used for sortPriority. SortPriority should be an integer greater than 0." + } + } + }, + "Field": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/FieldBase" + } + ], + "type": "object", + "additionalProperties": False, + "properties": { + "fieldCaption": {}, + "fieldAlias": {}, + "maxDecimalPlaces": {}, + "sortDirection": {}, + "sortPriority": {} + } + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/FieldBase" + } + ], + "type": "object", + "required": [ + "function" + ], + "additionalProperties": False, + "properties": { + "function": { + "$ref": "#/components/schemas/Function" + }, + "fieldCaption": {}, + "fieldAlias": {}, + "maxDecimalPlaces": {}, + "sortDirection": {}, + "sortPriority": {} + } + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/FieldBase" + } + ], + "type": "object", + "required": [ + "calculation" + ], + "additionalProperties": False, + "properties": { + "calculation": { + "type": "string", + "description": "A Tableau calculation which will be returned as a Field in the Query" + }, + "fieldCaption": {}, + "fieldAlias": {}, + "maxDecimalPlaces": {}, + "sortDirection": {}, + "sortPriority": {} + } + } + ] + }, + "FieldMetadata": { + "type": "object", + "description": "Describes a field in the datasource that can be used to create queries.", + "properties": { + "fieldName": { + "type": "string" + }, + "fieldCaption": { + "type": "string" + }, + "dataType": { + "type": "string", + "enum": [ + "INTEGER", + "REAL", + "STRING", + "DATETIME", + "BOOLEAN", + "DATE", + "SPATIAL", + "UNKNOWN" + ] + }, + "logicalTableId": { + "type": "string" + } + } + }, + "Filter": { + "type": "object", + "description": "A Filter to be used in the Query to filter on the datasource", + "required": ["field", "filterType"], + "properties": { + "field": { + "$ref": "#/components/schemas/FilterField" + }, + "filterType": { + "type": "string", + "enum": [ + "QUANTITATIVE_DATE", + "QUANTITATIVE_NUMERICAL", + "SET", + "MATCH", + "DATE", + "TOP" + ] + }, + "context": { + "type": "boolean", + "description": "Make the given filter a Context Filter, meaning that it's an independent filter. Any other filters that you set are defined as dependent filters because they process only the data that passes through the context filter", + "default": False + } + }, + "discriminator": { + "propertyName": "filterType", + "mapping": { + "QUANTITATIVE_DATE": "#/components/schemas/QuantitativeDateFilter", + "QUANTITATIVE_NUMERICAL": "#/components/schemas/QuantitativeNumericalFilter", + "SET": "#/components/schemas/SetFilter", + "MATCH": "#/components/schemas/MatchFilter", + "DATE": "#/components/schemas/RelativeDateFilter", + "TOP": "#/components/schemas/TopNFilter" + } + } + }, + "FilterField": { + "oneOf": [ + { + "required": ["fieldCaption"], + "additionalProperties": False, + "properties": { + "fieldCaption": { + "type": "string", + "description": "The caption of the field to filter on" + } + } + }, + { + "required": ["fieldCaption", "function"], + "additionalProperties": False, + "properties": { + "fieldCaption": { + "type": "string", + "description": "The caption of the field to filter on" + }, + "function": { + "$ref": "#/components/schemas/Function" + } + } + }, + { + "required": ["calculation"], + "additionalProperties": False, + "properties": { + "calculation": { + "type": "string", + "description": "A Tableau calculation which will be used to Filter on" + } + } + } + ] + }, + "Function": { + "type": "string", + "description": "The standard set of Tableau aggregations which can be applied to a Field", + "enum": [ + "SUM", + "AVG", + "MEDIAN", + "COUNT", + "COUNTD", + "MIN", + "MAX", + "STDEV", + "VAR", + "COLLECT", + "YEAR", + "QUARTER", + "MONTH", + "WEEK", + "DAY", + "TRUNC_YEAR", + "TRUNC_QUARTER", + "TRUNC_MONTH", + "TRUNC_WEEK", + "TRUNC_DAY" + ] + }, + "MatchFilter": { + "allOf": [ + { + "$ref": "#/components/schemas/Filter" + }, + { + "type": "object", + "description": "A Filter that can be used to match against String Fields", + "properties": { + "contains": { + "type": "string", + "description": "Matches when a Field contains this value" + }, + "startsWith": { + "type": "string", + "description": "Matches when a Field starts with this value" + }, + "endsWith": { + "type": "string", + "description": "Matches when a Field ends with this value" + }, + "exclude": { + "type": "boolean", + "description": "When true, the inverse of the matching logic will be used", + "default": False + } + } + } + ] + }, + "QuantitativeFilterBase": { + "allOf": [ + { + "$ref": "#/components/schemas/Filter" + }, + { + "type": "object", + "required": ["quantitativeFilterType"], + "properties": { + "quantitativeFilterType": { + "type": "string", + "enum": [ "RANGE", "MIN", "MAX", "ONLY_NULL", "ONLY_NON_NULL" ] + }, + "includeNulls": { + "type": "boolean", + "description": "Only applies to RANGE, MIN, and MAX Filters. Should nulls be returned or not. If not provided the default is to not include null values" + } + } + } + ] + }, + "QuantitativeNumericalFilter": { + "allOf": [ + { + "$ref": "#/components/schemas/QuantitativeFilterBase" + } + ], + "type": "object", + "description": "A Filter that can be used to find the minimumn, maximumn or range of numerical values of a Field", + "properties": { + "min": { + "type": "number", + "description": "A numerical value, either integer or floating point indicating the minimum value to filter upon. Required if using quantitativeFilterType RANGE or if using quantitativeFilterType MIN" + }, + "max": { + "type": "number", + "description": "A numerical value, either integer or floating point indicating the maximum value to filter upon. Required if using quantitativeFilterType RANGE or if using quantitativeFilterType MIN" + } + } + }, + "QuantitativeDateFilter": { + "allOf": [ + { + "$ref": "#/components/schemas/QuantitativeFilterBase" + } + ], + "type": "object", + "description": "A Filter that can be used to find the minimum, maximum or range of date values of a Field", + "properties": { + "minDate": { + "type": "string", + "format": "date", + "description": "An RFC 3339 date indicating the earliest date to filter upon. Required if using quantitativeFilterType RANGE or if using quantitativeFilterType MIN" + }, + "maxDate": { + "type": "string", + "format": "date", + "description": "An RFC 3339 date indicating the latest date to filter upon. Required if using quantitativeFilterType RANGE or if using quantitativeFilterType MIN" + } + } + }, + "Query": { + "description": "The Query is the fundamental interface to Headless BI. It holds the specific semantics to perform against the Data Source. A Query consists of an array of Fields to query against, and an optional array of filters to apply to the query", + "required": [ + "fields" + ], + "type": "object", + "properties": { + "fields": { + "description": "An array of Fields that define the query", + "type": "array", + "items": { + "$ref": "#/components/schemas/Field" + } + }, + "filters": { + "description": "An optional array of Filters to apply to the query", + "type": "array", + "items": { + "$ref": "#/components/schemas/Filter" + } + } + }, + "additionalProperties": False + }, + "SetFilter": { + "allOf": [ + { + "$ref": "#/components/schemas/Filter" + } + ], + "type": "object", + "description": "A Filter that can be used to filter on a specific set of values of a Field", + "required": [ + "values" + ], + "properties": { + "values": { + "type": "array", + "items": {}, + "description": "An array of values to filter on" + }, + "exclude": { + "type": "boolean", + "default": False + } + } + }, + "SortDirection": { + "type": "string", + "description": "The direction of the sort, either ascending or descending. If not supplied the default is ascending", + "enum": [ + "ASC", + "DESC" + ] + }, + "RelativeDateFilter": { + "allOf": [ + { + "$ref": "#/components/schemas/Filter" + }, + { + "type": "object", + "description": "A Filter that can be used to filter on dates using a specific anchor and fields that specify a relative date range to that anchor", + "required": ["periodType", "dateRangeType"], + "properties": { + "periodType": { + "type": "string", + "description": "The units of time in the relative date range", + "enum": [ + "MINUTES", + "HOURS", + "DAYS", + "WEEKS", + "MONTHS", + "QUARTERS", + "YEARS" + ] + }, + "dateRangeType": { + "type": "string", + "description": "The direction in the relative date range", + "enum": [ + "CURRENT", + "LAST", + "LASTN", + "NEXT", + "NEXTN", + "TODATE" + ] + }, + "rangeN": { + "type": "integer", + "description": "When dateRangeType is LASTN or NEXTN, this is the N value (how many years, months, etc.)." + }, + "anchorDate": { + "type": "string", + "format": "date", + "description": "When this field is not provided, defaults to today." + }, + "includeNulls": { + "type": "boolean", + "description": "Should nulls be returned or not. If not provided the default is to not include null values" + } + } + } + ] + }, + "TopNFilter": { + "allOf": [ + { + "$ref": "#/components/schemas/Filter" + }, + { + "type": "object", + "description": "A Filter that can be used to find the top or bottom number of Fields relative to the values in the fieldToMeasure", + "required": ["howMany, fieldToMeasure"], + "properties": { + "direction": { + "type": "string", + "enum": [ + "TOP", + "BOTTOM" + ], + "default": "TOP", + "description": "Top (Ascending) or Bottom (Descending) N" + }, + "howMany": { + "type": "integer", + "description": "The number of values from the Top or the Bottom of the given fieldToMeasure" + }, + "fieldToMeasure": { + "$ref": "#/components/schemas/FilterField" + } + } + } + ] + } + } + + @property + def baseurl(self): + return f"{self.parent_srv.server_address}/api/v1/vizql-data-service" + + + @api("3.5") + def query_vds_metadata(self, datasource_id, abort_on_error=False, parameters=None): + logger.info("Querying VizQL Data Service API") + + url = self.baseurl + "/read-metadata" + + try: + graphql_query = { + "datasource": {"datasourceLuid": datasource_id}} + except Exception as e: + raise InvalidGraphQLQuery("Must provide a string") + + # Setting content type because post_reuqest defaults to text/xml + server_response = self.post_request(url, graphql_query, content_type=JSON_CONTENT_TYPE, parameters=parameters) + results = server_response.json() + + if abort_on_error and results.get("errors", None): + raise GraphQLError(results["errors"]) + + return results + + @api("3.5") + def query_vds_data(self, query, datasource_id, variables=None, abort_on_error=False, parameters=None): + logger.info("Querying VizQL Data Service API") + + url = self.baseurl + "/query-datasource" + + try: + graphql_query = { + "query": query, + "datasource": {"datasourceLuid": datasource_id}} + except Exception as e: + raise InvalidGraphQLQuery("Must provide a string") + + # Setting content type because post_reuqest defaults to text/xml + server_response = self.post_request(url, graphql_query, content_type=JSON_CONTENT_TYPE, parameters=parameters) + results = server_response.json() + + if abort_on_error and results.get("errors", None): + raise GraphQLError(results["errors"]) + + return results \ No newline at end of file diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..819176f76 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -20,6 +20,7 @@ Subscriptions, Jobs, Metadata, + VizQL, Databases, Tables, Flows, @@ -150,6 +151,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.tasks = Tasks(self) self.subscriptions = Subscriptions(self) self.metadata = Metadata(self) + self.vizql = VizQL(self) self.databases = Databases(self) self.tables = Tables(self) self.webhooks = Webhooks(self) From a661d8ed6f8e1dabcaa1d0f050cb40d67695c9cd Mon Sep 17 00:00:00 2001 From: john Date: Mon, 5 May 2025 09:19:12 -0700 Subject: [PATCH 2/3] remove the redudant function --- .../server/endpoint/metadata_endpoint.py | 2 +- test/test_endpoint.py | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index e5dbcbcf8..30f97bf5a 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -68,7 +68,7 @@ def query(self, query, variables=None, abort_on_error=False, parameters=None): raise InvalidGraphQLQuery("Must provide a string") # Setting content type because post_reuqest defaults to text/xml - server_response = self.post_request(url, graphql_query, content_type="application/json", parameters=parameters) + server_response = self.post_request(url, graphql_query, parameters=parameters) results = server_response.json() if abort_on_error and results.get("errors", None): diff --git a/test/test_endpoint.py b/test/test_endpoint.py index ff1ef0f72..98888efa7 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -27,16 +27,6 @@ def test_fallback_request_logic(self) -> None: response = endpoint.get_request(url=url) self.assertIsNotNone(response) - def test_user_friendly_request_returns(self) -> None: - url = "http://test/" - endpoint = TSC.server.Endpoint(self.server) - with requests_mock.mock() as m: - m.get(url) - response = endpoint.send_request_while_show_progress_threaded( - endpoint.parent_srv.session.get, url=url, request_timeout=2 - ) - self.assertIsNotNone(response) - def test_blocking_request_raises_request_error(self) -> None: with pytest.raises(requests.exceptions.ConnectionError): url = "http://test/" From 3c7d2f8969747b290867d847b334db874fdcd463 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 5 May 2025 09:24:53 -0700 Subject: [PATCH 3/3] add the test cases for vizql endpoint --- test/test_vizql_endpoint.py | 195 ++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 test/test_vizql_endpoint.py diff --git a/test/test_vizql_endpoint.py b/test/test_vizql_endpoint.py new file mode 100644 index 000000000..6b04dcccc --- /dev/null +++ b/test/test_vizql_endpoint.py @@ -0,0 +1,195 @@ +import unittest +import json +import requests_mock + +import tableauserverclient as TSC +from ._utils import asset + + +class VizQLEndpointTests(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.5" # VizQL API requires 3.5+ + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.vizql.baseurl + + # Sample datasource id to use in tests + self.datasource_id = "01234567-89ab-cdef-0123-456789abcdef" + + # Sample response for metadata call + self.metadata_response = { + "fields": [ + { + "fieldName": "Field1", + "fieldCaption": "Field 1", + "dataType": "STRING" + }, + { + "fieldName": "Field2", + "fieldCaption": "Field 2", + "dataType": "INTEGER" + } + ], + "logicalTables": [ + { + "id": "LogicalTable1", + "caption": "Table 1" + } + ] + } + + # Sample response for query data call + self.data_response = { + "data": { + "rows": [ + ["value1", 1], + ["value2", 2] + ], + "columns": ["Field1", "Field2"] + } + } + + # Sample query object + self.sample_query = { + "fields": [ + { + "fieldCaption": "Field1" + }, + { + "fieldCaption": "Field2" + } + ] + } + + def test_query_vds_metadata(self) -> None: + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/read-metadata", + json=self.metadata_response, + status_code=200) + + # Call the method + result = self.server.vizql.query_vds_metadata(self.datasource_id) + + # Verify the result + self.assertEqual(result, self.metadata_response) + + # Verify the request was properly formed + self.assertEqual(m.last_request.json(), + {"datasource": {"datasourceLuid": self.datasource_id}}) + + def test_query_vds_metadata_with_parameters(self) -> None: + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/read-metadata", + json=self.metadata_response, + status_code=200) + + # Call the method with parameters + result = self.server.vizql.query_vds_metadata( + self.datasource_id, + parameters={"params": {"param1": "value1"}} + ) + + # Verify the result + self.assertEqual(result, self.metadata_response) + + # Verify the request parameters + self.assertEqual(m.last_request.qs, {"param1": ["value1"]}) + + def test_query_vds_metadata_abort_on_error(self) -> None: + error_response = { + "errors": [{"message": "Test error"}] + } + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/read-metadata", + json=error_response, + status_code=200) + + # Should raise exception when abort_on_error is True + with self.assertRaises(TSC.server.endpoint.exceptions.GraphQLError): + self.server.vizql.query_vds_metadata( + self.datasource_id, + abort_on_error=True + ) + + # Should not raise exception when abort_on_error is False + result = self.server.vizql.query_vds_metadata( + self.datasource_id, + abort_on_error=False + ) + self.assertEqual(result, error_response) + + def test_query_vds_data(self) -> None: + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/query-datasource", + json=self.data_response, + status_code=200) + + # Call the method + result = self.server.vizql.query_vds_data( + self.sample_query, + self.datasource_id + ) + + # Verify the result + self.assertEqual(result, self.data_response) + + # Verify the request was properly formed + self.assertEqual(m.last_request.json(), { + "query": self.sample_query, + "datasource": {"datasourceLuid": self.datasource_id} + }) + + def test_query_vds_data_with_parameters(self) -> None: + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/query-datasource", + json=self.data_response, + status_code=200) + + # Call the method with parameters + result = self.server.vizql.query_vds_data( + self.sample_query, + self.datasource_id, + parameters={"params": {"param1": "value1"}} + ) + + # Verify the result + self.assertEqual(result, self.data_response) + + # Verify the request parameters + self.assertEqual(m.last_request.qs, {"param1": ["value1"]}) + + def test_query_vds_data_abort_on_error(self) -> None: + error_response = { + "errors": [{"message": "Test error"}] + } + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/query-datasource", + json=error_response, + status_code=200) + + # Should raise exception when abort_on_error is True + with self.assertRaises(TSC.server.endpoint.exceptions.GraphQLError): + self.server.vizql.query_vds_data( + self.sample_query, + self.datasource_id, + abort_on_error=True + ) + + # Should not raise exception when abort_on_error is False + result = self.server.vizql.query_vds_data( + self.sample_query, + self.datasource_id, + abort_on_error=False + ) + self.assertEqual(result, error_response) + + def test_get_before_signin(self) -> None: + self.server._auth_token = None + with self.assertRaises(TSC.NotSignedInError): + self.server.vizql.query_vds_metadata(self.datasource_id) \ No newline at end of file