|  | 
| 1 | 1 | # -*- coding: utf-8 -*- | 
| 2 | 2 | # (c) 2023 Andreas Motl <[email protected]> | 
| 3 | 3 | import calendar | 
|  | 4 | +import functools | 
| 4 | 5 | import json | 
|  | 6 | +from collections import OrderedDict | 
| 5 | 7 | from decimal import Decimal | 
| 6 | 8 | from copy import deepcopy | 
| 7 |  | -from datetime import datetime, date | 
|  | 9 | +from datetime import datetime, date, timezone | 
| 8 | 10 | 
 | 
| 9 | 11 | import crate.client.http | 
| 10 | 12 | import pytz | 
| 11 | 13 | import requests | 
| 12 | 14 | from crate import client | 
|  | 15 | +from crate.client.converter import DefaultTypeConverter | 
| 13 | 16 | from crate.client.exceptions import ProgrammingError | 
| 14 | 17 | from funcy import project | 
|  | 18 | +from munch import Munch | 
| 15 | 19 | from twisted.logger import Logger | 
| 16 | 20 | 
 | 
| 17 | 21 | from kotori.daq.storage.util import format_chunk | 
| 18 | 22 | 
 | 
| 19 | 23 | log = Logger() | 
| 20 | 24 | 
 | 
| 21 | 25 | 
 | 
| 22 |  | -class CrateDBAdapter(object): | 
|  | 26 | +class CrateDBAdapter: | 
| 23 | 27 |     """ | 
| 24 | 28 |     Kotori database backend adapter for CrateDB. | 
| 25 | 29 | 
 | 
| @@ -86,6 +90,79 @@ def create_table(self, tablename): | 
| 86 | 90 |         cursor.execute(sql_ddl) | 
| 87 | 91 |         cursor.close() | 
| 88 | 92 | 
 | 
|  | 93 | +    def query(self, expression: str, tdata: Munch = None): | 
|  | 94 | +        """ | 
|  | 95 | +        Query CrateDB and respond with results in suitable shape. | 
|  | 96 | +
 | 
|  | 97 | +        Make sure to synchronize data by using `REFRESH TABLE ...` before running | 
|  | 98 | +        the actual `SELECT` statement. This is applicable in test case scenarios. | 
|  | 99 | +
 | 
|  | 100 | +        Response format:: | 
|  | 101 | +
 | 
|  | 102 | +            [ | 
|  | 103 | +              { | 
|  | 104 | +                "time": ..., | 
|  | 105 | +                "tags": {"city": "berlin", "location": "balcony"}, | 
|  | 106 | +                "fields": {"temperature": 42.42, "humidity": 84.84}, | 
|  | 107 | +              }, | 
|  | 108 | +              ... | 
|  | 109 | +            ] | 
|  | 110 | +
 | 
|  | 111 | +        TODO: Unify with `kotori.test.util:CrateDBWrapper.query`. | 
|  | 112 | +        """ | 
|  | 113 | + | 
|  | 114 | +        log.info(f"Database query: {expression}") | 
|  | 115 | + | 
|  | 116 | +        tdata = tdata or {} | 
|  | 117 | + | 
|  | 118 | +        # Before reading data from CrateDB, synchronize it. | 
|  | 119 | +        # Currently, it is mostly needed to satisfy synchronization constraints when running the test suite. | 
|  | 120 | +        # However, users also may expect to see data "immediately". On the other hand, in order to satisfy | 
|  | 121 | +        # different needs, this should be made configurable per realm, channel and/or request. | 
|  | 122 | +        # TODO: Maybe just _optionally_ synchronize with the database when reading data. | 
|  | 123 | +        if tdata: | 
|  | 124 | +            refresh_sql = f"REFRESH TABLE {self.get_tablename(tdata)}" | 
|  | 125 | +            self.execute(refresh_sql) | 
|  | 126 | + | 
|  | 127 | +        def dict_from_row(columns, row): | 
|  | 128 | +            """ | 
|  | 129 | +            https://stackoverflow.com/questions/3300464/how-can-i-get-dict-from-sqlite-query | 
|  | 130 | +            https://stackoverflow.com/questions/4147707/python-mysqldb-sqlite-result-as-dictionary | 
|  | 131 | +            """ | 
|  | 132 | +            return dict(zip(columns, row)) | 
|  | 133 | + | 
|  | 134 | +        def record_from_dict(item): | 
|  | 135 | +            record = OrderedDict() | 
|  | 136 | +            record.update({"time": item["time"]}) | 
|  | 137 | +            record.update(item["tags"]) | 
|  | 138 | +            record.update(item["fields"]) | 
|  | 139 | +            return record | 
|  | 140 | + | 
|  | 141 | +        # Query database, with convenience data type converters. Assume timestamps to be in UTC. | 
|  | 142 | +        cursor = self.db_client.cursor(converter=DefaultTypeConverter(), time_zone=timezone.utc) | 
|  | 143 | +        cursor.execute(expression) | 
|  | 144 | +        data_raw = cursor.fetchall() | 
|  | 145 | + | 
|  | 146 | +        # Provide fully-qualified records to downstream components, including column names. | 
|  | 147 | +        column_names = [column_info[0] for column_info in cursor.description] | 
|  | 148 | +        data_tags_fields = map(functools.partial(dict_from_row, column_names), data_raw) | 
|  | 149 | + | 
|  | 150 | +        # Bring results into classic "records" shape. | 
|  | 151 | +        data_records = map(record_from_dict, data_tags_fields) | 
|  | 152 | + | 
|  | 153 | +        cursor.close() | 
|  | 154 | +        return data_records | 
|  | 155 | + | 
|  | 156 | +    def execute(self, expression: str): | 
|  | 157 | +        """ | 
|  | 158 | +        Execute a database query, using a cursor, and return its results. | 
|  | 159 | +        """ | 
|  | 160 | +        cursor = self.db_client.cursor() | 
|  | 161 | +        cursor.execute(expression) | 
|  | 162 | +        result = cursor._result | 
|  | 163 | +        cursor.close() | 
|  | 164 | +        return result | 
|  | 165 | + | 
| 89 | 166 |     def write(self, meta, data): | 
| 90 | 167 |         """ | 
| 91 | 168 |         Format ingress data chunk and store it into database table. | 
|  | 
0 commit comments