diff --git a/docs/source/Resources/SQL/SQL_Type.rst b/docs/source/Resources/SQL/SQL_Type.rst new file mode 100644 index 0000000..12d77b7 --- /dev/null +++ b/docs/source/Resources/SQL/SQL_Type.rst @@ -0,0 +1,320 @@ +**SQL Type** +============ + + +.. _SQLParam: + +SQLParam +-------- + + **Attributes:** + + | key: str + | Positional parameter placeholder, such as ``$1``. + + | value: str + | Value bound to the placeholder. + + +.. _SQLCreateInfo: + +SQLCreateInfo +------------- + + **Attributes:** + + | name: str + | Name of the query. Required on create and replace. + + | query: str + | TagoSQL query text. Required on create and replace. + + | description: Optional[str] + | Human-readable description of the query. + + | params: Optional[List[:ref:`SQLParam`]] + | Default positional parameters. + + | cache_enabled: Optional[bool] + | Whether execution results are cached. + + | cache_ttl_seconds: Optional[int] + | Cache time-to-live in seconds. + + | rate_limit_rpm: Optional[int] + | Execution rate limit in requests per minute. + + | active: Optional[bool] + | Whether the query is active. + + | tags: Optional[List[:ref:`TagsObj`]] + | List of tags associated with the query. + + +.. _SQLInfo: + +SQLInfo +------- + + **Attributes:** + + | id: :ref:`GenericID` + | Unique identifier for the query. + + | name: str + | Name of the query. + + | description: Optional[str] + | Human-readable description of the query. + + | query: str + | TagoSQL query text. + + | params: Optional[List[:ref:`SQLParam`]] + | Default positional parameters. + + | cache_enabled: Optional[bool] + | Whether execution results are cached. + + | cache_ttl_seconds: Optional[int] + | Cache time-to-live in seconds. + + | rate_limit_rpm: Optional[int] + | Execution rate limit in requests per minute. + + | active: Optional[bool] + | Whether the query is active. + + | tags: Optional[List[:ref:`TagsObj`]] + | List of tags associated with the query. + + | version: int + | Current stored version number. + + | cache_version: int + | Cache generation counter, bumped when cached results are invalidated. + + | versions: Dict[str, Any] + | Mapping of version number to stored snapshot metadata. + + | created_at: datetime + | Date and time when the query was created. + + | updated_at: datetime + | Date and time when the query was last updated. + + | session_context: bool + | Read-only. True when the query text uses session functions. Never accepted as input. + + +.. _SQLFilter: + +SQLFilter +--------- + + **Attributes:** + + | name: str + | Name to filter queries by. + + | active: bool + | Active state to filter queries by. + + | tags: Optional[List[:ref:`TagsObj`]] + | Tags to filter queries by. + + +.. _SQLQuery: + +SQLQuery(:ref:`Query`) +---------------------- + + **Attributes:** + + | fields: Optional[List[Literal["id", "name", "description", "query", "params", "cache_enabled", "cache_ttl_seconds", "rate_limit_rpm", "active", "tags", "version", "created_at", "updated_at"]]] + | List of fields to include in the query results. + + | filter: Optional[:ref:`SQLFilter`] + | Filter criteria for the query. + + +.. _SQLExecuteObj: + +SQLExecuteObj +------------- + + **Attributes:** + + | params: Optional[List[:ref:`SQLParam`]] + | Positional parameters overriding the saved defaults per key. + + | test: Optional[bool] + | When True, skips the result cache entirely. + + | after_device: Optional[:ref:`GenericID`] + | Device ID used to resolve session context for the execution. + + +.. _SQLColumn: + +SQLColumn +--------- + + **Attributes:** + + | name: str + | Column name. + + | type: Literal["string", "number", "timestamp", "boolean", "json"] + | Column data type. + + +.. _SQLExecuteResult: + +SQLExecuteResult +---------------- + + **Attributes:** + + | columns: List[:ref:`SQLColumn`] + | Result columns with their types. + + | rows: List[dict] + | Result rows keyed by column name. + + | row_count: int + | Number of rows returned. + + | execution_ms: int + | Server-side execution time in milliseconds. + + | served_from_cache: bool + | True when the result was served from the cache. + + +.. _SQLVersionInfo: + +SQLVersionInfo +-------------- + + **Attributes:** + + | query: str + | Query text stored in this version. + + | params: List[:ref:`SQLParam`] + | Parameters stored in this version. + + | created_at: Optional[datetime] + | Date and time when this version was stored. + + +.. _SQLTableInfo: + +SQLTableInfo +------------ + + **Attributes:** + + | function: str + | Virtual table family function name. + + | label: str + | Human-readable label. + + | tag_form: Optional[str] + | Tag form used to resolve the table. + + | columns: List[:ref:`SQLColumn`] + | Typed columns exposed by the table. + + | dynamic: Optional[bool] + | Whether the table columns are resolved dynamically. + + +.. _SQLResourceItem: + +SQLResourceItem +--------------- + + **Attributes:** + + | id: :ref:`GenericID` + | Resource identifier. + + | name: str + | Resource name. + + +.. _SQLFunctionInfo: + +SQLFunctionInfo +--------------- + + **Attributes:** + + | name: str + | Function name. + + | kind: Literal["aggregate", "session"] + | Function category. + + | args: List[str] + | Argument names. + + | description: str + | Human-readable description. + + | example: Optional[str] + | Present only on session functions; carries the COALESCE authoring idiom. + + +.. _SQLTablesResources: + +SQLTablesResources +------------------ + + **Attributes:** + + | devices: List[:ref:`SQLResourceItem`] + | Profile devices available to queries. + + | entities: List[:ref:`SQLResourceItem`] + | Profile entities available to queries. + + +.. _SQLTablesResult: + +SQLTablesResult +--------------- + + **Attributes:** + + | tables: List[:ref:`SQLTableInfo`] + | Virtual table families with their typed columns. + + | resources: :ref:`SQLTablesResources` + | Profile devices and entities. + + | functions: List[:ref:`SQLFunctionInfo`] + | Aggregate and session functions available to queries. + + +.. _SQLTablesQuery: + +SQLTablesQuery +-------------- + + **Attributes:** + + | filter: Optional[str] + | Substring filter applied to the catalog. + + | amount: Optional[int] + | Number of items per page. + + | page: Optional[int] + | Page number. + + | entity_id: Optional[:ref:`GenericID`] + | Entity ID used to resolve one entity's columns. diff --git a/docs/source/Resources/SQL/index.rst b/docs/source/Resources/SQL/index.rst new file mode 100644 index 0000000..8ad80b8 --- /dev/null +++ b/docs/source/Resources/SQL/index.rst @@ -0,0 +1,254 @@ +**SQL** +======= + +Manage and run TagoSQL queries on your profile. + +======= +list +======= + +Retrieves a list with all TagoSQL queries from the profile. + +See: `TagoSQL Queries `_ + + **Parameters:** + + | *Optional* **queryObj**: :ref:`SQLQuery` + | Query parameters to filter the results. + + .. code-block:: + :caption: **Default queryObj:** + + queryObj = { + "page": 1, + "fields": ["id", "name", "tags"], + "filter": {}, + "amount": 20 + } + + **Returns:** + + | list[:ref:`SQLInfo`] + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "SQL Query" / "Access" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.list({ + "page": 1, + "fields": ["id", "name", "tags"], + "amount": 20 + }) + print(result) # [ { 'id': 'query-id-123', 'name': 'My query', ... } ] + + +======= +create +======= + +Creates a new TagoSQL query on the profile. + +See: `TagoSQL Queries `_ + + **Parameters:** + + | **sqlObj**: :ref:`SQLCreateInfo` + | Query definition. + + **Returns:** + + | :ref:`SQLInfo` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.create({ + "name": "Latest temperature", + "query": "SELECT variable, value, time FROM device($1) AS d WHERE variable = 'temperature' ORDER BY time DESC LIMIT 10", + "params": [{"key": "$1", "value": "my-device-id"}] + }) + print(result) # { 'id': 'query-id-123', 'name': 'Latest temperature', ... } + + +======= +info +======= + +Retrieves detailed information about a specific TagoSQL query. + +See: `TagoSQL Queries `_ + + **Parameters:** + + | **sqlID**: str + | Query ID. + + **Returns:** + + | :ref:`SQLInfo` + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "SQL Query" / "Access" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.info("query-id-123") + print(result) # { 'id': 'query-id-123', 'name': 'My query', 'query': 'SELECT ...', ... } + + +======= +edit +======= + +Replaces a TagoSQL query. The query is re-validated and its cached results are dropped; a new version is stored when the query or the params change. + +See: `TagoSQL Queries `_ + + **Parameters:** + + | **sqlID**: str + | Query ID. + + | **sqlObj**: :ref:`SQLCreateInfo` + | New query definition. + + **Returns:** + + | :ref:`SQLInfo` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.edit("query-id-123", { + "name": "Latest temperature", + "query": "SELECT variable, value, time FROM device($1) AS d ORDER BY time DESC LIMIT 20", + "params": [{"key": "$1", "value": "my-device-id"}] + }) + print(result) # { 'id': 'query-id-123', 'version': 2, ... } + + +======= +delete +======= + +Deletes a TagoSQL query from the profile. + +See: `TagoSQL Queries `_ + + **Parameters:** + + | **sqlID**: str + | Query ID. + + **Returns:** + + | dict[str, str] + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.delete("query-id-123") + print(result) # { 'id': 'query-id-123' } + + +========== +getVersion +========== + +Retrieves a historical snapshot (query and params) of a TagoSQL query. To restore it, send the snapshot's query and params back with ``edit``. + +See: `TagoSQL Queries `_ + + **Parameters:** + + | **sqlID**: str + | Query ID. + + | **version**: int + | Version number to retrieve. + + **Returns:** + + | :ref:`SQLVersionInfo` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.getVersion("query-id-123", 1) + print(result) # { 'query': 'SELECT ...', 'params': [], 'created_at': ... } + + +======= +execute +======= + +Executes a TagoSQL query. Params sent here override the saved defaults per key; ``test: True`` skips the result cache entirely. + +See: `Executing Queries `_ + + **Parameters:** + + | **sqlID**: str + | Query ID. + + | *Optional* **executeObj**: :ref:`SQLExecuteObj` + | Execution options. + + **Returns:** + + | :ref:`SQLExecuteResult` + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "SQL Query" / "Execute" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.execute("query-id-123", { + "params": [{"key": "$1", "value": "my-device-id"}] + }) + print(result) # { 'columns': [...], 'rows': [...], 'row_count': 1, ... } + + +======= +tables +======= + +Retrieves the TagoSQL schema discovery catalog: the virtual table families with their typed columns, plus the profile's devices and entities. Pass ``entity_id`` to resolve one entity's columns. + +See: `Available Tables `_ + + **Parameters:** + + | *Optional* **queryObj**: :ref:`SQLTablesQuery` + | Query parameters to filter the catalog. + + **Returns:** + + | :ref:`SQLTablesResult` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.sql.tables({"filter": "sensor", "amount": 20}) + print(result) # { 'tables': [...], 'resources': { 'devices': [...], 'entities': [...] } } + +.. toctree:: + + +.. toctree:: + + SQL_Type diff --git a/docs/source/Resources/index.rst b/docs/source/Resources/index.rst index 158d67a..204a4f9 100644 --- a/docs/source/Resources/index.rst +++ b/docs/source/Resources/index.rst @@ -42,3 +42,4 @@ Instance Run/index Secrets/index ServiceAuthorization/index + SQL/index diff --git a/pyproject.toml b/pyproject.toml index 69426c6..d7a8fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tagoio-sdk" -version = "5.1.3" +version = "5.1.4" description = "Official Python SDK for TagoIO" authors = [{name = "TagoIO Inc.", email = "contact@tago.io"}] license = {text = "Apache-2.0"} diff --git a/src/tagoio_sdk/modules/Resources/Resources.py b/src/tagoio_sdk/modules/Resources/Resources.py index ea04102..3c0ac3d 100644 --- a/src/tagoio_sdk/modules/Resources/Resources.py +++ b/src/tagoio_sdk/modules/Resources/Resources.py @@ -21,6 +21,7 @@ from .Run import Run from .Secrets import Secrets from .Service_Authorization import ServiceAuthorization +from .SQL import SQL class Resources(TagoIOModule): @@ -41,6 +42,7 @@ def __init__(self, params: Optional[GenericModuleParams] = None): self.profile = Profile(params) self.run = Run(params) self.secrets = Secrets(params) + self.sql = SQL(params) self.serviceAuthorization = ServiceAuthorization(params) self.integration = Integration(params) self.account = Account(params) diff --git a/src/tagoio_sdk/modules/Resources/SQL.py b/src/tagoio_sdk/modules/Resources/SQL.py new file mode 100644 index 0000000..5f321e1 --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/SQL.py @@ -0,0 +1,255 @@ +from typing import Dict +from typing import List +from typing import Optional +from urllib.parse import quote + +from tagoio_sdk.common import Cache +from tagoio_sdk.common.Common_Type import GenericID +from tagoio_sdk.common.tagoio_module import TagoIOModule +from tagoio_sdk.modules.Resources.SQL_Types import SQLCreateInfo +from tagoio_sdk.modules.Resources.SQL_Types import SQLExecuteObj +from tagoio_sdk.modules.Resources.SQL_Types import SQLExecuteResult +from tagoio_sdk.modules.Resources.SQL_Types import SQLInfo +from tagoio_sdk.modules.Resources.SQL_Types import SQLQuery +from tagoio_sdk.modules.Resources.SQL_Types import SQLTablesQuery +from tagoio_sdk.modules.Resources.SQL_Types import SQLTablesResult +from tagoio_sdk.modules.Resources.SQL_Types import SQLVersionInfo +from tagoio_sdk.modules.Utils.dateParser import dateParser +from tagoio_sdk.modules.Utils.dateParser import dateParserList + + +class SQL(TagoIOModule): + def list(self, queryObj: Optional[SQLQuery] = None) -> List[SQLInfo]: + """ + @description: + Retrieves a list with all TagoSQL queries from the profile. + + @see: + https://docs.tago.io/docs/tagoio/tagosql/queries TagoSQL Queries + + @example: + If receive an error "Authorization Denied", check policy **SQL Query** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.sql.list({ + "page": 1, + "fields": ["id", "name", "tags"], + "amount": 20, + }) + print(result) # [{'id': 'query-id-123', 'name': 'My query', ...}] + ``` + """ + queryObj = queryObj or {} + params = { + "page": queryObj.get("page", 1), + "fields": queryObj.get("fields", ["id", "name", "tags"]), + "filter": queryObj.get("filter", {}), + "amount": queryObj.get("amount", 20), + } + orderBy = queryObj.get("orderBy") + if isinstance(orderBy, (list, tuple)) and len(orderBy) == 2: + params["orderBy"] = f"{orderBy[0]},{orderBy[1]}" + + result = self.doRequest( + { + "path": "/sql", + "method": "GET", + "params": params, + } + ) + return dateParserList(result, ["created_at", "updated_at"]) + + def create(self, sqlObj: SQLCreateInfo) -> SQLInfo: + """ + @description: + Creates a new TagoSQL query on the profile. Requires a profile token + (or an analysis token granted **Create** in Access Management). + + @see: + https://docs.tago.io/docs/tagoio/tagosql/queries TagoSQL Queries + + @example: + ```python + resources = Resources() + result = resources.sql.create({ + "name": "Latest temperature", + "query": "SELECT variable, value, time FROM device($1) AS d WHERE variable = 'temperature' ORDER BY time DESC LIMIT 10", + "params": [{"key": "$1", "value": "my-device-id"}], + }) + print(result) # {'id': 'query-id-123', 'name': 'Latest temperature', ...} + ``` + """ + result = self.doRequest( + { + "path": "/sql", + "method": "POST", + "body": sqlObj, + } + ) + Cache.clear_cache() + return dateParser(result, ["created_at", "updated_at"]) + + def info(self, sqlID: GenericID) -> SQLInfo: + """ + @description: + Retrieves detailed information about a specific TagoSQL query. + + @see: + https://docs.tago.io/docs/tagoio/tagosql/queries TagoSQL Queries + + @example: + If receive an error "Authorization Denied", check policy **SQL Query** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.sql.info("query-id-123") + print(result) # {'id': 'query-id-123', 'name': 'My query', 'query': 'SELECT ...', ...} + ``` + """ + result = self.doRequest( + { + "path": f"/sql/{sqlID}", + "method": "GET", + } + ) + return dateParser(result, ["created_at", "updated_at"]) + + def edit(self, sqlID: GenericID, sqlObj: SQLCreateInfo) -> SQLInfo: + """ + @description: + Replaces a TagoSQL query. The query is re-validated and its cached + results are dropped; a new version is stored when the query or the + params change. + + @see: + https://docs.tago.io/docs/tagoio/tagosql/queries TagoSQL Queries + + @example: + ```python + resources = Resources() + result = resources.sql.edit("query-id-123", { + "name": "Latest temperature", + "query": "SELECT variable, value, time FROM device($1) AS d ORDER BY time DESC LIMIT 20", + "params": [{"key": "$1", "value": "my-device-id"}], + }) + print(result) # {'id': 'query-id-123', 'version': 2, ...} + ``` + """ + result = self.doRequest( + { + "path": f"/sql/{sqlID}", + "method": "PUT", + "body": sqlObj, + } + ) + Cache.clear_cache() + return dateParser(result, ["created_at", "updated_at"]) + + def delete(self, sqlID: GenericID) -> Dict[str, str]: + """ + @description: + Deletes a TagoSQL query from the profile. + + @see: + https://docs.tago.io/docs/tagoio/tagosql/queries TagoSQL Queries + + @example: + ```python + resources = Resources() + result = resources.sql.delete("query-id-123") + print(result) # {'id': 'query-id-123'} + ``` + """ + result = self.doRequest( + { + "path": f"/sql/{sqlID}", + "method": "DELETE", + } + ) + Cache.clear_cache() + return result + + def getVersion(self, sqlID: GenericID, version: int) -> SQLVersionInfo: + """ + @description: + Retrieves a historical snapshot (query and params) of a TagoSQL query. + To restore it, send the snapshot's query and params back with `edit`. + + @see: + https://docs.tago.io/docs/tagoio/tagosql/queries TagoSQL Queries + + @example: + ```python + resources = Resources() + result = resources.sql.getVersion("query-id-123", 1) + print(result) # {'query': 'SELECT ...', 'params': [], 'created_at': ...} + ``` + """ + result = self.doRequest( + { + "path": f"/sql/{sqlID}/version/{version}", + "method": "GET", + } + ) + return dateParser(result, ["created_at"]) + + def execute( + self, sqlID: GenericID, executeObj: Optional[SQLExecuteObj] = None + ) -> SQLExecuteResult: + """ + @description: + Executes a TagoSQL query. Params sent here override the saved + defaults per key; `test: True` skips the result cache entirely. + + @see: + https://docs.tago.io/docs/tagoio/tagosql/executing-queries Executing Queries + + @example: + If receive an error "Authorization Denied", check policy **SQL Query** / **Execute** in Access Management. + ```python + resources = Resources() + result = resources.sql.execute("query-id-123", { + "params": [{"key": "$1", "value": "my-device-id"}], + }) + print(result) # {'columns': [...], 'rows': [...], 'row_count': 1, ...} + ``` + """ + return self.doRequest( + { + "path": f"/sql/{sqlID}/execute", + "method": "POST", + "body": executeObj or {}, + } + ) + + def tables(self, queryObj: Optional[SQLTablesQuery] = None) -> SQLTablesResult: + """ + @description: + Retrieves the TagoSQL schema discovery catalog: the virtual table + families with their typed columns, plus the profile's devices and + entities. Pass `entity_id` to resolve one entity's columns. + + @see: + https://docs.tago.io/docs/tagoio/tagosql/tables Available Tables + + @example: + ```python + resources = Resources() + result = resources.sql.tables({"filter": "sensor", "amount": 20}) + print(result) # {'tables': [...], 'resources': {'devices': [...], 'entities': [...]}} + ``` + """ + queryObj = queryObj or {} + # ? doRequest expands object filters into filter[...] pairs; this + # ? endpoint's filter is a plain substring, so it goes on the path. + params = {key: value for key, value in queryObj.items() if key != "filter"} + path = "/sql/tables" + filterValue = queryObj.get("filter") + if isinstance(filterValue, str) and filterValue: + path = f"/sql/tables?filter={quote(filterValue)}" + return self.doRequest( + { + "path": path, + "method": "GET", + "params": params, + } + ) diff --git a/src/tagoio_sdk/modules/Resources/SQL_Types.py b/src/tagoio_sdk/modules/Resources/SQL_Types.py new file mode 100644 index 0000000..e6dfb93 --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/SQL_Types.py @@ -0,0 +1,153 @@ +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Literal +from typing import Optional +from typing import TypedDict +from typing import Union + +from tagoio_sdk.common.Common_Type import GenericID +from tagoio_sdk.common.Common_Type import Query +from tagoio_sdk.common.Common_Type import TagsObj + + +class SQLParam(TypedDict): + """Positional parameter as ``{"key": "$n", "value": "..."}``.""" + + key: str + value: str + + +class _SQLCreateRequired(TypedDict): + name: str + query: str + + +class SQLCreateInfo(_SQLCreateRequired, total=False): + # ? name and query are required on create/replace; everything else optional. + description: Optional[str] + params: Optional[List[SQLParam]] + cache_enabled: Optional[bool] + cache_ttl_seconds: Optional[int] + rate_limit_rpm: Optional[int] + active: Optional[bool] + tags: Optional[List[TagsObj]] + + +class SQLInfo(TypedDict, total=False): + # ? Response shape: total=False because list projection (fields[]) may + # ? return partial objects. + id: GenericID + name: str + description: Optional[str] + query: str + params: Optional[List[SQLParam]] + cache_enabled: Optional[bool] + cache_ttl_seconds: Optional[int] + rate_limit_rpm: Optional[int] + active: Optional[bool] + tags: Optional[List[TagsObj]] + version: int + cache_version: int + # ? Mapping of version number -> stored snapshot metadata (jsonb on the wire). + versions: Dict[str, Any] + created_at: datetime + updated_at: datetime + # Read-only, derived server-side: True when the query text uses session + # functions. Never accepted as input. + session_context: bool + + +class SQLFilter(TypedDict, total=False): + name: str + active: bool + tags: Optional[List[TagsObj]] + + +class SQLQuery(Query): + fields: Optional[ + List[ + Literal[ + "id", + "name", + "description", + "query", + "params", + "cache_enabled", + "cache_ttl_seconds", + "rate_limit_rpm", + "active", + "tags", + "version", + "created_at", + "updated_at", + ] + ] + ] + filter: Optional[SQLFilter] + + +class SQLExecuteObj(TypedDict, total=False): + params: Optional[List[SQLParam]] + test: Optional[bool] + after_device: Optional[GenericID] + + +class SQLColumn(TypedDict): + name: str + type: Literal["string", "number", "timestamp", "boolean", "json"] + + +class SQLExecuteResult(TypedDict): + columns: List[SQLColumn] + rows: List[dict[str, Union[str, float, bool, None, dict, list]]] + row_count: int + execution_ms: int + served_from_cache: bool + + +class SQLVersionInfo(TypedDict): + query: str + params: List[SQLParam] + created_at: Optional[datetime] + + +class SQLTableInfo(TypedDict, total=False): + function: str + label: str + tag_form: Optional[str] + columns: List[SQLColumn] + dynamic: Optional[bool] + + +class SQLResourceItem(TypedDict): + id: GenericID + name: str + + +class SQLFunctionInfo(TypedDict, total=False): + name: str + kind: Literal["aggregate", "session"] + args: List[str] + description: str + # Present only on session functions; carries the COALESCE authoring idiom. + example: Optional[str] + + +class SQLTablesResources(TypedDict): + devices: List[SQLResourceItem] + entities: List[SQLResourceItem] + + +class SQLTablesResult(TypedDict): + tables: List[SQLTableInfo] + resources: SQLTablesResources + functions: List[SQLFunctionInfo] + + +class SQLTablesQuery(TypedDict, total=False): + filter: Optional[str] + amount: Optional[int] + page: Optional[int] + entity_id: Optional[GenericID] diff --git a/tests/Resources/test_sql.py b/tests/Resources/test_sql.py new file mode 100644 index 0000000..4b30f47 --- /dev/null +++ b/tests/Resources/test_sql.py @@ -0,0 +1,194 @@ +import os + +from requests_mock.mocker import Mocker + +from tagoio_sdk.modules.Resources.Resources import Resources + + +os.environ["T_ANALYSIS_TOKEN"] = "your_token_value" + +QUERY_ROW = { + "id": "sql-id-123", + "name": "Latest temperature", + "tags": [{"key": "audience", "value": "dashboard"}], + "session_context": True, + "created_at": "2026-07-01T12:00:00.000Z", + "updated_at": "2026-07-02T12:00:00.000Z", +} + +EXECUTE_RESULT = { + "columns": [ + {"name": "variable", "type": "string"}, + {"name": "value", "type": "number"}, + ], + "rows": [{"variable": "temperature", "value": 41.2}], + "row_count": 1, + "execution_ms": 12, + "served_from_cache": False, +} + + +def testSqlMethodList(requests_mock: Mocker) -> None: + requests_mock.get( + "https://api.tago.io/sql", json={"status": True, "result": [QUERY_ROW]} + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.list( + {"page": 1, "fields": ["id", "name", "tags"], "amount": 20} + ) + + assert isinstance(result, list) + assert result[0]["id"] == "sql-id-123" + assert result[0]["session_context"] is True + + +def testSqlMethodCreate(requests_mock: Mocker) -> None: + requests_mock.post( + "https://api.tago.io/sql", json={"status": True, "result": QUERY_ROW} + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.create( + { + "name": "Latest temperature", + "query": "SELECT variable, value FROM device($1) AS d LIMIT 10", + "params": [{"key": "$1", "value": "my-device-id"}], + } + ) + + assert result["id"] == "sql-id-123" + + +def testSqlMethodInfo(requests_mock: Mocker) -> None: + requests_mock.get( + "https://api.tago.io/sql/sql-id-123", json={"status": True, "result": QUERY_ROW} + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.info("sql-id-123") + + assert result["name"] == "Latest temperature" + assert result["session_context"] is True + + +def testSqlMethodEdit(requests_mock: Mocker) -> None: + requests_mock.put( + "https://api.tago.io/sql/sql-id-123", json={"status": True, "result": QUERY_ROW} + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.edit( + "sql-id-123", + { + "name": "Latest temperature", + "query": "SELECT variable, value FROM device($1) AS d LIMIT 20", + }, + ) + + assert result["id"] == "sql-id-123" + + +def testSqlMethodDelete(requests_mock: Mocker) -> None: + requests_mock.delete( + "https://api.tago.io/sql/sql-id-123", + json={"status": True, "result": {"id": "sql-id-123"}}, + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.delete("sql-id-123") + + assert result == {"id": "sql-id-123"} + + +def testSqlMethodGetVersion(requests_mock: Mocker) -> None: + requests_mock.get( + "https://api.tago.io/sql/sql-id-123/version/1", + json={ + "status": True, + "result": { + "query": "SELECT 1", + "params": [], + "created_at": "2026-07-01T12:00:00.000Z", + }, + }, + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.getVersion("sql-id-123", 1) + + assert result["query"] == "SELECT 1" + + +def testSqlMethodExecute(requests_mock: Mocker) -> None: + requests_mock.post( + "https://api.tago.io/sql/sql-id-123/execute", + json={"status": True, "result": EXECUTE_RESULT}, + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.execute( + "sql-id-123", {"params": [{"key": "$1", "value": "my-device-id"}]} + ) + + assert result["row_count"] == 1 + assert result["served_from_cache"] is False + + +def testSqlMethodTables(requests_mock: Mocker) -> None: + requests_mock.get( + "https://api.tago.io/sql/tables", + json={ + "status": True, + "result": { + "tables": [ + {"function": "device", "label": "Device Data", "columns": []} + ], + "resources": {"devices": [], "entities": []}, + "functions": [ + { + "name": "count", + "kind": "aggregate", + "args": ["column"], + "description": "Row count", + }, + { + "name": "session_user_tag", + "kind": "session", + "args": ["key"], + "description": "The executing user's value for a tag key, filled by the server", + "example": "COALESCE(session_user_tag('key'), '...')", + }, + ], + }, + }, + ) + + resources = Resources({"token": "your_token_value"}) + result = resources.sql.tables({"filter": "sensor"}) + + assert result["tables"][0]["function"] == "device" + assert result["functions"][0]["name"] == "count" + assert result["functions"][1]["kind"] == "session" + assert "COALESCE" in result["functions"][1]["example"] + # ? The substring filter must ride on the path, not as an expanded param. + assert "filter=sensor" in requests_mock.last_request.url + + +def testSqlMethodTablesOmitsNoneFilter(requests_mock: Mocker) -> None: + requests_mock.get( + "https://api.tago.io/sql/tables", + json={ + "status": True, + "result": { + "tables": [], + "resources": {"devices": [], "entities": []}, + "functions": [], + }, + }, + ) + + resources = Resources({"token": "your_token_value"}) + resources.sql.tables({"filter": None}) + + assert "filter" not in requests_mock.last_request.url diff --git a/uv.lock b/uv.lock index f5e1848..03ba6a4 100644 --- a/uv.lock +++ b/uv.lock @@ -421,7 +421,7 @@ wheels = [ [[package]] name = "tagoio-sdk" -version = "5.1.3" +version = "5.1.4" source = { editable = "." } dependencies = [ { name = "python-dateutil" },