|
12 | 12 | Websocket Auth & Client for QlikSense |
13 | 13 | """ |
14 | 14 | import json |
| 15 | +import re |
15 | 16 | import traceback |
16 | 17 | from pathlib import Path |
17 | | -from typing import Dict, List, Optional |
| 18 | +from typing import Dict, List, Optional, Set |
18 | 19 |
|
19 | 20 | from pydantic import ValidationError |
20 | 21 |
|
|
28 | 29 | CREATE_SHEET_SESSION, |
29 | 30 | GET_DOCS_LIST_REQ, |
30 | 31 | GET_LOADMODEL_LAYOUT, |
| 32 | + GET_SCRIPT, |
31 | 33 | GET_SHEET_LAYOUT, |
| 34 | + GET_TABLES_AND_KEYS, |
32 | 35 | OPEN_DOC_REQ, |
33 | 36 | ) |
34 | 37 | from metadata.ingestion.source.dashboard.qliksense.models import ( |
35 | 38 | QlikDashboard, |
36 | 39 | QlikDashboardResult, |
37 | 40 | QlikDataModelResult, |
| 41 | + QlikFields, |
| 42 | + QlikScriptResult, |
38 | 43 | QlikSheet, |
39 | 44 | QlikSheetResult, |
40 | 45 | QlikTable, |
| 46 | + QlikTableConnectionProp, |
| 47 | + QlikTablesAndKeysResponse, |
41 | 48 | ) |
42 | 49 | from metadata.utils.constants import UTF_8 |
43 | 50 | from metadata.utils.helpers import clean_uri |
@@ -174,26 +181,129 @@ def get_dashboard_charts(self, dashboard_id: str) -> List[QlikSheet]: |
174 | 181 | logger.warning("Failed to fetch the dashboard charts") |
175 | 182 | return [] |
176 | 183 |
|
| 184 | + def _get_tables_via_get_tables_and_keys(self) -> Optional[List[QlikTable]]: |
| 185 | + """ |
| 186 | + Fetch all tables using GetTablesAndKeys API. |
| 187 | + This returns all tables in the app including those |
| 188 | + created via load scripts, not just Data Manager tables. |
| 189 | + """ |
| 190 | + resp = self._websocket_send_request(GET_TABLES_AND_KEYS, response=True) |
| 191 | + data = QlikTablesAndKeysResponse(**resp) |
| 192 | + if not data.result or not data.result.qtr: |
| 193 | + return None |
| 194 | + tables = [] |
| 195 | + for table_record in data.result.qtr: |
| 196 | + fields = [ |
| 197 | + QlikFields( |
| 198 | + name=field.qName, |
| 199 | + id=field.qOriginalFieldName or field.qName, |
| 200 | + ) |
| 201 | + for field in table_record.qFields or [] |
| 202 | + ] |
| 203 | + tables.append( |
| 204 | + QlikTable( |
| 205 | + tableName=table_record.qName, |
| 206 | + id=table_record.qName, |
| 207 | + connectorProperties=table_record.qConnectorProperties |
| 208 | + or QlikTableConnectionProp(), |
| 209 | + fields=fields, |
| 210 | + ) |
| 211 | + ) |
| 212 | + return tables |
| 213 | + |
| 214 | + def _get_tables_via_load_model(self) -> List[QlikTable]: |
| 215 | + """ |
| 216 | + Fallback: fetch tables from the LoadModel object. |
| 217 | + Only returns tables created via Data Manager. |
| 218 | + """ |
| 219 | + self._websocket_send_request(APP_LOADMODEL_REQ) |
| 220 | + models = self._websocket_send_request(GET_LOADMODEL_LAYOUT, response=True) |
| 221 | + data_models = QlikDataModelResult(**models) |
| 222 | + layout = data_models.result.qLayout |
| 223 | + if isinstance(layout, list): |
| 224 | + tables = [] |
| 225 | + for layout in data_models.result.qLayout: |
| 226 | + tables.extend(layout.value.tables) |
| 227 | + return tables |
| 228 | + return layout.tables |
| 229 | + |
177 | 230 | def get_dashboard_models(self) -> List[QlikTable]: |
178 | 231 | """ |
179 | | - Get dahsboard chart list |
| 232 | + Get all data model tables for the current app. |
| 233 | + Uses GetTablesAndKeys to capture all tables including |
| 234 | + those created via load scripts. |
| 235 | + Falls back to LoadModel if GetTablesAndKeys fails. |
180 | 236 | """ |
181 | 237 | try: |
182 | | - self._websocket_send_request(APP_LOADMODEL_REQ) |
183 | | - models = self._websocket_send_request(GET_LOADMODEL_LAYOUT, response=True) |
184 | | - data_models = QlikDataModelResult(**models) |
185 | | - layout = data_models.result.qLayout |
186 | | - if isinstance(layout, list): |
187 | | - tables = [] |
188 | | - for layout in data_models.result.qLayout: |
189 | | - tables.extend(layout.value.tables) |
| 238 | + tables = self._get_tables_via_get_tables_and_keys() |
| 239 | + if tables is not None: |
190 | 240 | return tables |
191 | | - return layout.tables |
| 241 | + except Exception: |
| 242 | + logger.debug(traceback.format_exc()) |
| 243 | + logger.warning("GetTablesAndKeys failed, falling back to LoadModel") |
| 244 | + try: |
| 245 | + return self._get_tables_via_load_model() |
192 | 246 | except Exception: |
193 | 247 | logger.debug(traceback.format_exc()) |
194 | 248 | logger.warning("Failed to fetch the dashboard datamodels") |
195 | 249 | return [] |
196 | 250 |
|
| 251 | + def get_script(self) -> Optional[str]: |
| 252 | + """ |
| 253 | + Retrieve the load script from the current app |
| 254 | + using the GetScript Engine API. |
| 255 | + """ |
| 256 | + try: |
| 257 | + resp = self._websocket_send_request(GET_SCRIPT, response=True) |
| 258 | + script_result = QlikScriptResult(**resp) |
| 259 | + if script_result.result and script_result.result.qScript: |
| 260 | + return script_result.result.qScript |
| 261 | + except Exception: |
| 262 | + logger.debug(traceback.format_exc()) |
| 263 | + logger.warning("Failed to fetch the app load script") |
| 264 | + return None |
| 265 | + |
| 266 | + def get_script_tables(self) -> Dict[str, Set[str]]: |
| 267 | + """ |
| 268 | + Parse the load script to extract source SQL tables |
| 269 | + for each Qlik table defined in the script. |
| 270 | +
|
| 271 | + Returns a mapping of qlik_table_name -> set of source table names |
| 272 | + found in FROM/JOIN clauses. |
| 273 | + """ |
| 274 | + table_source_map: Dict[str, Set[str]] = {} |
| 275 | + script = self.get_script() |
| 276 | + if not script: |
| 277 | + return table_source_map |
| 278 | + |
| 279 | + sections = re.split(r"(?:^|\n)\s*(?:\[([^\]]+)\]|(\w+))\s*:", script) |
| 280 | + |
| 281 | + current_table = None |
| 282 | + for i, section in enumerate(sections): |
| 283 | + if section is None: |
| 284 | + continue |
| 285 | + stripped = section.strip() |
| 286 | + if not stripped: |
| 287 | + continue |
| 288 | + if i % 3 in (1, 2): |
| 289 | + current_table = stripped |
| 290 | + continue |
| 291 | + if current_table: |
| 292 | + from_join_tables = re.findall( |
| 293 | + r"(?:FROM|JOIN)\s+((?:(?:\[[a-zA-Z0-9_ ]+\]|[a-zA-Z0-9_]+)\.)*(?:\[[a-zA-Z0-9_ ]+\]|[a-zA-Z0-9_]+))", |
| 294 | + stripped, |
| 295 | + re.IGNORECASE, |
| 296 | + ) |
| 297 | + sql_tables = { |
| 298 | + re.sub(r"[\[\]]", "", t) |
| 299 | + for t in from_join_tables |
| 300 | + if "." in re.sub(r"[\[\]]", "", t) |
| 301 | + } |
| 302 | + if sql_tables: |
| 303 | + table_source_map.setdefault(current_table, set()).update(sql_tables) |
| 304 | + |
| 305 | + return table_source_map |
| 306 | + |
197 | 307 | def get_dashboard_for_test_connection(self): |
198 | 308 | try: |
199 | 309 | self.connect_websocket() |
|
0 commit comments