quillsql 2.1.6__py3-none-any.whl → 2.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
quillsql/core.py CHANGED
@@ -3,15 +3,27 @@ from dotenv import load_dotenv
3
3
 
4
4
  import requests
5
5
  import redis
6
+ from .utils import Filter, convert_custom_filter
7
+ import json
8
+ from enum import Enum
6
9
 
7
10
 
8
11
  from quillsql.db.cached_connection import CachedConnection
9
- from quillsql.db.db_helper import get_db_credentials, get_schema_column_info_by_db, get_schema_tables_by_db
12
+ from quillsql.db.db_helper import (
13
+ get_db_credentials,
14
+ get_schema_column_info_by_db,
15
+ get_schema_tables_by_db,
16
+ )
10
17
  from quillsql.utils.schema_conversion import convert_type_to_postgres
11
18
  from quillsql.utils.run_query_processes import (
12
19
  array_to_map,
13
20
  remove_fields,
14
21
  )
22
+ from quillsql.utils.tenants import extract_tenant_ids
23
+ from quillsql.utils.pivot_template import (
24
+ parse_distinct_values,
25
+ hydrate_pivot_template,
26
+ )
15
27
 
16
28
  load_dotenv()
17
29
 
@@ -20,8 +32,11 @@ DEV_HOST = "http://localhost:8080"
20
32
  PROD_HOST = "https://quill-344421.uc.r.appspot.com"
21
33
  HOST = DEV_HOST if ENV == "development" else PROD_HOST
22
34
 
35
+ SINGLE_TENANT = "QUILL_SINGLE_TENANT"
36
+ ALL_TENANTS = "QUILL_ALL_TENANTS"
37
+ FLAG_TASKS = {'dashboard', 'report', 'item', 'report-info', 'filter-options'}
23
38
 
24
- ## Quill - Fullstack API Platform for Dashboards and Reporting.
39
+ # Quill - Fullstack API Platform for Dashboards and Reporting.
25
40
  class Quill:
26
41
  def __init__(
27
42
  self,
@@ -32,13 +47,27 @@ class Quill:
32
47
  metadataServerURL=None,
33
48
  cache=None,
34
49
  ):
50
+ if private_key is None:
51
+ raise ValueError("Private key is required")
52
+ if database_type is None:
53
+ raise ValueError("Database type is required")
54
+ if database_connection_string is None and database_config is None:
55
+ raise ValueError("You must provide either DatabaseConnectionString or DatabaseConfig")
56
+
35
57
  # Handles both dsn-style connection strings (eg. "dbname=test password=secret" )
36
58
  # as well as url-style connection strings (eg. "postgres://foo@db.com")
37
- self.baseUrl = metadataServerURL if metadataServerURL != None else HOST
38
- if database_connection_string != None:
39
- self.target_connection = CachedConnection(database_type, get_db_credentials(database_type, database_connection_string), cache, True)
59
+ self.baseUrl = metadataServerURL if metadataServerURL is not None else HOST
60
+ if database_connection_string is not None:
61
+ self.target_connection = CachedConnection(
62
+ database_type,
63
+ get_db_credentials(database_type, database_connection_string),
64
+ cache,
65
+ True,
66
+ )
40
67
  else:
41
- self.target_connection = CachedConnection(database_type, database_config, cache, False)
68
+ self.target_connection = CachedConnection(
69
+ database_type, database_config, cache, False
70
+ )
42
71
  self.private_key = private_key
43
72
 
44
73
  def get_cache(self, cache_config):
@@ -52,99 +81,434 @@ class Quill:
52
81
  )
53
82
  return None
54
83
 
55
- def query(self, org_id, data):
56
- metadata = data.get("metadata")
84
+ def query(
85
+ self,
86
+ tenants,
87
+ metadata,
88
+ flags=None,
89
+ filters: list[Filter] = None,
90
+ admin_enabled: bool = None,
91
+ ):
92
+ if not tenants:
93
+ raise ValueError("You may not pass an empty tenants array.")
94
+ if flags and not flags:
95
+ raise ValueError("You may not pass an empty flags array.")
96
+
97
+ responseMetadata = {}
57
98
  if not metadata:
58
- return {"error": "400", "errorMessage": "Missing metadata."}
99
+ return {"error": "Missing metadata.", "status": "error", "data": {}}
59
100
 
60
101
  task = metadata.get("task")
61
102
  if not task:
62
- return {"error": "400", "errorMessage": "Missing task."}
103
+ return {"error": "Missing task.", "status": "error", "data": {}}
63
104
 
64
105
  try:
65
- pre_query_results = self.run_queries(
66
- metadata.get("preQueries"),
67
- self.target_connection.database_type,
68
- metadata.get('databaseType'),
69
- metadata,
70
- metadata.get("runQueryConfig"),
71
- ) # used by the view task to get non-sensitive data
72
- if metadata.get("runQueryConfig") and metadata.get("runQueryConfig").get('overridePost'):
106
+ # Set tenant IDs in the connection
107
+ self.target_connection.tenant_ids = extract_tenant_ids(tenants)
108
+
109
+ # Handle pivot-template task
110
+ if task == "pivot-template":
111
+ # Step 1: Get pivot template and queries from server
112
+ pivot_payload = {
113
+ **metadata,
114
+ "tenants": tenants,
115
+ "flags": flags,
116
+ "adminEnabled": admin_enabled,
117
+ }
118
+ if filters is not None:
119
+ pivot_payload["sdkFilters"] = [
120
+ convert_custom_filter(f) for f in filters
121
+ ]
122
+ pivot_template_response = self.post_quill(
123
+ "pivot-template",
124
+ pivot_payload,
125
+ )
126
+
127
+ if pivot_template_response.get("error"):
128
+ return {
129
+ "status": "error",
130
+ "error": pivot_template_response.get("error"),
131
+ "data": pivot_template_response.get("metadata") or {},
132
+ }
133
+
134
+ template = pivot_template_response.get("metadata", {}).get("template")
135
+ config = pivot_template_response.get("metadata", {}).get("config")
136
+ distinct_values_query = pivot_template_response.get("metadata", {}).get("distinctValuesQuery")
137
+ row_count_query = pivot_template_response.get("metadata", {}).get("rowCountQuery")
138
+
139
+ # Step 2: Run the distinct values query to get unique values
140
+ distinct_values = []
141
+ if distinct_values_query:
142
+ distinct_value_results = self.run_queries(
143
+ [distinct_values_query],
144
+ self.target_connection.database_type,
145
+ metadata.get("databaseType"),
146
+ metadata,
147
+ None
148
+ )
149
+
150
+ # Parse distinct values from database results
151
+ distinct_values = parse_distinct_values(
152
+ distinct_value_results["queryResults"][0],
153
+ config.get("databaseType")
154
+ )
155
+
156
+ # Step 3: Hydrate the template with the distinct values
157
+ try:
158
+ final_query = hydrate_pivot_template(template, distinct_values, config)
159
+ except Exception as err:
160
+ return {
161
+ "status": "error",
162
+ "error": f"Failed to hydrate pivot template: {str(err)}",
163
+ "data": {},
164
+ }
165
+
166
+ # Step 4: Run queries - pivot query and optional row count query
167
+ queries_to_run = [final_query]
168
+ if row_count_query:
169
+ # Hydrate the rowCountQuery with the same distinct values
170
+ hydrated_row_count_query = hydrate_pivot_template(
171
+ row_count_query,
172
+ distinct_values,
173
+ config
174
+ )
175
+ queries_to_run.append(hydrated_row_count_query)
176
+
177
+ final_results = self.run_queries(
178
+ queries_to_run,
179
+ self.target_connection.database_type,
180
+ metadata.get("databaseType"),
181
+ metadata,
182
+ pivot_template_response.get("metadata", {}).get("runQueryConfig")
183
+ )
184
+
185
+ responseMetadata = pivot_template_response.get("metadata") or {}
186
+ # Set rows and fields from first query result (the pivot query)
187
+ if final_results.get("queryResults") and len(final_results["queryResults"]) >= 1:
188
+ query_results = final_results["queryResults"][0]
189
+ if query_results.get("rows"):
190
+ responseMetadata["rows"] = query_results["rows"]
191
+ if query_results.get("fields"):
192
+ responseMetadata["fields"] = query_results["fields"]
193
+
194
+ # Remove internal SDK fields before returning to frontend
195
+ if "template" in responseMetadata:
196
+ del responseMetadata["template"]
197
+ if "distinctValuesQuery" in responseMetadata:
198
+ del responseMetadata["distinctValuesQuery"]
199
+ if "rowCountQuery" in responseMetadata:
200
+ del responseMetadata["rowCountQuery"]
201
+
73
202
  return {
74
- "data": pre_query_results ,
75
- "status": "success"
203
+ "data": responseMetadata,
204
+ "queries": final_results,
205
+ "status": "success",
76
206
  }
207
+
208
+ # Handle tenant flags synthesis
209
+ tenant_flags = None
210
+ if (task in FLAG_TASKS and
211
+ tenants[0] != ALL_TENANTS and
212
+ tenants[0] != SINGLE_TENANT
213
+ ):
214
+
215
+ response = self.post_quill(
216
+ 'tenant-mapped-flags',
217
+ {
218
+ 'reportId': metadata.get('reportId') or metadata.get('dashboardItemId'),
219
+ 'clientId': metadata.get('clientId'),
220
+ 'dashboardName': metadata.get('name'),
221
+ 'tenants': tenants,
222
+ 'flags': flags,
223
+ 'adminEnabled': admin_enabled,
224
+ },
225
+ )
226
+
227
+ if response.get('error'):
228
+ return {
229
+ 'status': 'error',
230
+ 'error': response.get('error'),
231
+ 'data': response.get('metadata') or {},
232
+ }
233
+
234
+ flag_query_results = self.run_queries(
235
+ response.get('queries'),
236
+ self.target_connection.database_type,
237
+ )
238
+
239
+ tenant_flags = [
240
+ {
241
+ 'tenantField': tenant_field,
242
+ 'flags': list(set(row['quill_flag'] for row in query_result['rows']))
243
+ }
244
+ for tenant_field, query_result in zip(
245
+ response['metadata']['queryOrder'],
246
+ flag_query_results['queryResults']
247
+ )
248
+ ]
249
+ elif tenants[0] == SINGLE_TENANT and flags:
250
+ if flags and isinstance(flags[0], dict):
251
+ tenant_flags = [{'tenantField': SINGLE_TENANT, 'flags': flags}]
252
+ else:
253
+ tenant_flags = flags
254
+
255
+ pre_query_results = (
256
+ self.run_queries(
257
+ metadata.get("preQueries"),
258
+ self.target_connection.database_type,
259
+ metadata.get("databaseType"),
260
+ metadata,
261
+ metadata.get("runQueryConfig"),
262
+ )
263
+ if metadata.get("preQueries")
264
+ else {}
265
+ )
266
+
267
+ if metadata.get("runQueryConfig") and metadata.get("runQueryConfig").get(
268
+ "overridePost"
269
+ ):
270
+ return {"data": pre_query_results, "status": "success"}
77
271
  view_query = None
78
- if (metadata.get("preQueries")):
272
+ if metadata.get("preQueries"):
79
273
  view_query = metadata.get("preQueries")[0]
274
+ pre_query_columns = (
275
+ pre_query_results.get("columns")
276
+ if metadata.get("runQueryConfig")
277
+ and metadata.get("runQueryConfig").get("getColumns")
278
+ else None
279
+ )
80
280
  payload = {
81
281
  **metadata,
82
- "orgId": org_id,
83
- "preQueryResults": pre_query_results,
84
- "viewQuery": view_query
282
+ "tenants": tenants,
283
+ "flags": tenant_flags,
284
+ "viewQuery": view_query,
285
+ "preQueryResultsColumns": pre_query_columns,
286
+ "adminEnabled": admin_enabled,
85
287
  }
288
+ if filters is not None:
289
+ payload["sdkFilters"] = [convert_custom_filter(f) for f in filters]
86
290
  quill_results = self.post_quill(metadata.get("task"), payload)
87
291
  if quill_results.get("error"):
88
- return {"error": quill_results.get("error"), "status": "error"}
89
- # If there is no metedata in the quill results, create one
292
+ responseMetadata = quill_results.get("metadata")
293
+ response = {
294
+ "error": quill_results.get("error"),
295
+ "status": "error",
296
+ "data": {},
297
+ }
298
+ if responseMetadata:
299
+ response["data"] = responseMetadata
300
+ return response
301
+
302
+ # If there is no metadata in the quill results, create one
90
303
  if not quill_results.get("metadata"):
91
304
  quill_results["metadata"] = {}
92
305
  metadata = quill_results.get("metadata")
93
- final_query_results = self.run_queries(
94
- quill_results.get("queries"), self.target_connection.database_type, metadata.get('databaseType'), metadata, metadata.get("runQueryConfig")
306
+ responseMetadata = metadata
307
+ results = self.run_queries(
308
+ quill_results.get("queries"),
309
+ self.target_connection.database_type,
310
+ metadata.get("databaseType"),
311
+ metadata,
312
+ metadata.get("runQueryConfig"),
95
313
  )
96
- # Quick hack to make the sdk work with the Frontend
97
- if len(final_query_results.get("queryResults")) == 1:
98
- query_result = final_query_results.get("queryResults")[0]
314
+
315
+ should_wrap_results = isinstance(results, list) or not results
316
+ if should_wrap_results:
317
+ normalized_results = {
318
+ "queryResults": results if isinstance(results, list) else []
319
+ }
320
+ else:
321
+ normalized_results = results
322
+
323
+ if (
324
+ should_wrap_results
325
+ and not normalized_results.get("queryResults")
326
+ and quill_results.get("queries")
327
+ ):
328
+ normalized_results["queryResults"] = (
329
+ normalized_results.get("queryResults") or []
330
+ )
331
+
332
+ if (
333
+ normalized_results.get("mapped_array")
334
+ and metadata.get("runQueryConfig", {}).get("arrayToMap")
335
+ ):
336
+ array_to_map = metadata["runQueryConfig"]["arrayToMap"]
337
+ for array, index in zip(
338
+ normalized_results["mapped_array"],
339
+ range(len(normalized_results["mapped_array"])),
340
+ ):
341
+ responseMetadata[array_to_map["arrayName"]][index][array_to_map["field"]] = array
342
+ del normalized_results["mapped_array"]
343
+
344
+ query_results_list = normalized_results.get("queryResults") or []
345
+ if len(query_results_list) == 1:
346
+ query_result = query_results_list[0]
99
347
  quill_results["metadata"]["rows"] = query_result.get("rows")
100
348
  quill_results["metadata"]["fields"] = query_result.get("fields")
101
349
  return {
102
350
  "data": quill_results.get("metadata"),
103
- "queries": final_query_results,
351
+ "queries": normalized_results,
104
352
  "status": "success",
105
353
  }
106
354
 
107
355
  except Exception as err:
108
- return {"error": str(err), "status": "error"}
356
+ if task == "update-view":
357
+ self.post_quill(
358
+ "set-broken-view",
359
+ {
360
+ "table": metadata.get("name"),
361
+ "clientId": metadata.get("clientId"),
362
+ "error": str(err),
363
+ "adminEnabled": admin_enabled,
364
+ },
365
+ )
366
+ return {
367
+ "error": str(err).splitlines()[0],
368
+ "status": "error",
369
+ "data": responseMetadata,
370
+ }
371
+
372
+ def apply_limit(self, query, limit):
373
+ # Simple logic: if query already has a limit, don't add another
374
+ if getattr(self.target_connection, 'database_type', '').lower() == 'mssql':
375
+ import re
376
+ if re.search(r'SELECT TOP \\d+', query, re.IGNORECASE):
377
+ return query
378
+ return re.sub(r'select', f'SELECT TOP {limit}', query, flags=re.IGNORECASE)
379
+ else:
380
+ if 'limit ' in query.lower():
381
+ return query
382
+ return f"{query.rstrip(';')} limit {limit}"
109
383
 
110
- def run_queries(self, queries, pkDatabaseType, databaseType, metadata=None, runQueryConfig=None):
384
+ def run_queries(
385
+ self, queries, pkDatabaseType, databaseType=None, metadata=None, runQueryConfig=None
386
+ ):
111
387
  results = {}
112
388
  if not queries:
113
389
  return {"queryResults": []}
114
390
  if databaseType and databaseType.lower() != pkDatabaseType.lower():
115
391
  return {"dbMismatched": True, "backendDatabaseType": pkDatabaseType}
116
- if runQueryConfig and runQueryConfig.get("getColumnsForSchema"):
117
- return {"queryResults": []}
118
392
  if runQueryConfig and runQueryConfig.get("arrayToMap"):
119
- array_to_map(
120
- queries, runQueryConfig.get("arrayToMap"), metadata, self.target_connection
393
+ mapped_array = array_to_map(
394
+ queries,
395
+ runQueryConfig.get("arrayToMap"),
396
+ metadata,
397
+ self.target_connection,
121
398
  )
122
- return {"queryResults": []}
399
+
400
+ return {"queryResults": [], "mapped_array": mapped_array}
123
401
  elif runQueryConfig and runQueryConfig.get("getColumns"):
124
- query_results = self.target_connection.query(queries[0].strip().rstrip(";") + ' limit 1')
125
- results["columns"] = [{'name': result['name'], 'dataTypeID': convert_type_to_postgres(result['dataTypeID'])} for result in query_results['fields']]
402
+ query_results = self.target_connection.query(
403
+ queries[0].strip().rstrip(";") + " limit 1000"
404
+ )
405
+ results["columns"] = [
406
+ {
407
+ "fieldType": convert_type_to_postgres(result["dataTypeID"]),
408
+ "name": result["name"],
409
+ "displayName": result["name"],
410
+ "isVisible": True,
411
+ "field": result["name"],
412
+ }
413
+ for result in query_results["fields"]
414
+ ]
415
+ elif runQueryConfig and runQueryConfig.get("getColumnsForSchema"):
416
+ query_results = []
417
+ for table in queries:
418
+ if not table.get("viewQuery") or (
419
+ not table.get("isSelectStar") and not table.get("customFieldInfo")
420
+ ):
421
+ query_results.append(table)
422
+ continue
423
+
424
+ limit = ""
425
+ if runQueryConfig.get("limitBy"):
426
+ limit = f" limit {runQueryConfig.get('limitBy')}"
427
+
428
+ try:
429
+ query_result = self.target_connection.query(
430
+ f"{table['viewQuery'].strip().rstrip(';')} {limit}"
431
+ )
432
+ columns = [
433
+ {
434
+ "fieldType": convert_type_to_postgres(field["dataTypeID"]),
435
+ "name": field["name"],
436
+ "displayName": field["name"],
437
+ "isVisible": True,
438
+ "field": field["name"],
439
+ }
440
+ for field in query_result["fields"]
441
+ ]
442
+ query_results.append(
443
+ {**table, "columns": columns, "rows": query_result["rows"]}
444
+ )
445
+ except Exception as e:
446
+ query_results.append(
447
+ {**table, "error": f"Error fetching columns {e}"}
448
+ )
449
+
450
+ results["queryResults"] = query_results
451
+ if runQueryConfig.get("fieldsToRemove"):
452
+ results["queryResults"] = [
453
+ {
454
+ **table,
455
+ "columns": [
456
+ column
457
+ for column in table.get("columns", [])
458
+ if column["name"] not in runQueryConfig["fieldsToRemove"]
459
+ ],
460
+ }
461
+ for table in query_results
462
+ ]
463
+ return results
126
464
  elif runQueryConfig and runQueryConfig.get("getTables"):
127
465
  tables = get_schema_tables_by_db(
128
- self.target_connection.database_type,
129
- self.target_connection.connection,
130
- runQueryConfig['schemaNames']
466
+ self.target_connection.database_type,
467
+ self.target_connection.connection,
468
+ runQueryConfig["schemaNames"],
131
469
  )
132
470
  schema = get_schema_column_info_by_db(
133
- self.target_connection.database_type,
134
- self.target_connection.connection,
135
- runQueryConfig['schemaNames'],
136
- tables
471
+ self.target_connection.database_type,
472
+ self.target_connection.connection,
473
+ runQueryConfig["schemaNames"],
474
+ tables,
137
475
  )
138
476
  results["queryResults"] = schema
477
+ elif runQueryConfig and runQueryConfig.get("runIndividualQueries"):
478
+ # so that one query doesn't fail the whole thing
479
+ # the only reason this isn't the default behavior is for backwards compatibility
480
+ query_results = []
481
+ for query in queries:
482
+ try:
483
+ run_query = query
484
+ if runQueryConfig.get("limitBy"):
485
+ run_query = self.apply_limit(query, runQueryConfig["limitBy"])
486
+ query_result = self.target_connection.query(run_query)
487
+ query_results.append(query_result)
488
+ except Exception as e:
489
+ query_results.append({
490
+ "query": query,
491
+ "error": str(e),
492
+ })
493
+ results["queryResults"] = query_results
139
494
  else:
140
495
  if runQueryConfig and runQueryConfig.get("limitThousand"):
141
- queries = [query.strip().rstrip(";") + " limit 1000" for query in queries]
496
+ queries = [
497
+ query.strip().rstrip(";") + " limit 1000" for query in queries
498
+ ]
142
499
  elif runQueryConfig and runQueryConfig.get("limitBy"):
143
- queries = [query.strip().rstrip(";") + " limit " + runQueryConfig.get("limitBy") for query in queries]
500
+ queries = [
501
+ query.strip().rstrip(";")
502
+ + f" limit {runQueryConfig.get('limitBy')}"
503
+ for query in queries
504
+ ]
144
505
  query_results = [self.target_connection.query(query) for query in queries]
145
506
  results["queryResults"] = query_results
146
507
  if runQueryConfig and runQueryConfig.get("fieldsToRemove"):
147
- results["queryResults"] = [ remove_fields(query_result, runQueryConfig.get("fieldsToRemove")) for query_result in results["queryResults"]]
508
+ results["queryResults"] = [
509
+ remove_fields(query_result, runQueryConfig.get("fieldsToRemove"))
510
+ for query_result in results["queryResults"]
511
+ ]
148
512
  if runQueryConfig and runQueryConfig.get("convertDatatypes"):
149
513
  for query_result in results["queryResults"]:
150
514
  query_result["fields"] = [
@@ -160,9 +524,18 @@ class Quill:
160
524
  ]
161
525
 
162
526
  return results
163
-
527
+
164
528
  def post_quill(self, path, payload):
529
+ # Custom JSON Encoder to handle Enums
530
+ class EnumEncoder(json.JSONEncoder):
531
+ def default(self, obj):
532
+ if isinstance(obj, Enum):
533
+ return obj.value # Convert enum to its value (string in this case)
534
+ return super().default(obj)
535
+
165
536
  url = f"{self.baseUrl}/sdk/{path}"
166
- headers = {"Authorization": f"Bearer {self.private_key}"}
167
- response = requests.post(url, json=payload, headers=headers)
537
+ # Set content type to application/json
538
+ headers = {"Authorization": f"Bearer {self.private_key}", "Content-Type": "application/json"}
539
+ encoded = json.dumps(payload, cls=EnumEncoder)
540
+ response = requests.post(url, data=encoded, headers=headers)
168
541
  return response.json()
quillsql/db/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  # __init__.py
2
2
 
3
- from .cached_connection import CachedConnection
3
+ from .cached_connection import CachedConnection