jl-db-comp 0.1.0__py3-none-any.whl → 0.1.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.
Files changed (37) hide show
  1. jl_db_comp/_version.py +1 -1
  2. jl_db_comp/connections.py +173 -0
  3. jl_db_comp/routes.py +353 -7
  4. jl_db_comp/tests/test_routes.py +1 -1
  5. {jl_db_comp/labextension → jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp}/package.json +3 -2
  6. {jl_db_comp-0.1.0.data → jl_db_comp-0.1.1.data}/data/share/jupyter/labextensions/jl_db_comp/schemas/jl_db_comp/package.json.orig +2 -1
  7. {jl_db_comp-0.1.0.data → jl_db_comp-0.1.1.data}/data/share/jupyter/labextensions/jl_db_comp/schemas/jl_db_comp/plugin.json +4 -4
  8. jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/171.d366980651e0db8d978c.js +1 -0
  9. jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/728.6552504d5b9b27551bc5.js +1 -0
  10. jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/remoteEntry.48c365d3de0d860be537.js +1 -0
  11. jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/third-party-licenses.json +16 -0
  12. {jl_db_comp-0.1.0.dist-info → jl_db_comp-0.1.1.dist-info}/METADATA +103 -1
  13. jl_db_comp-0.1.1.dist-info/RECORD +20 -0
  14. jl_db_comp/labextension/build_log.json +0 -728
  15. jl_db_comp/labextension/schemas/jl_db_comp/package.json.orig +0 -214
  16. jl_db_comp/labextension/schemas/jl_db_comp/plugin.json +0 -27
  17. jl_db_comp/labextension/static/lib_index_js.a0969ed73da70f2cc451.js +0 -561
  18. jl_db_comp/labextension/static/lib_index_js.a0969ed73da70f2cc451.js.map +0 -1
  19. jl_db_comp/labextension/static/remoteEntry.5763ae02737e035e938c.js +0 -560
  20. jl_db_comp/labextension/static/remoteEntry.5763ae02737e035e938c.js.map +0 -1
  21. jl_db_comp/labextension/static/style.js +0 -4
  22. jl_db_comp/labextension/static/style_index_js.5364c7419a6b9db5d727.js +0 -508
  23. jl_db_comp/labextension/static/style_index_js.5364c7419a6b9db5d727.js.map +0 -1
  24. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/build_log.json +0 -728
  25. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/package.json +0 -219
  26. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/lib_index_js.a0969ed73da70f2cc451.js +0 -561
  27. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/lib_index_js.a0969ed73da70f2cc451.js.map +0 -1
  28. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/remoteEntry.5763ae02737e035e938c.js +0 -560
  29. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/remoteEntry.5763ae02737e035e938c.js.map +0 -1
  30. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/style_index_js.5364c7419a6b9db5d727.js +0 -508
  31. jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/style_index_js.5364c7419a6b9db5d727.js.map +0 -1
  32. jl_db_comp-0.1.0.dist-info/RECORD +0 -33
  33. {jl_db_comp-0.1.0.data → jl_db_comp-0.1.1.data}/data/etc/jupyter/jupyter_server_config.d/jl_db_comp.json +0 -0
  34. {jl_db_comp-0.1.0.data → jl_db_comp-0.1.1.data}/data/share/jupyter/labextensions/jl_db_comp/install.json +0 -0
  35. {jl_db_comp-0.1.0.data → jl_db_comp-0.1.1.data}/data/share/jupyter/labextensions/jl_db_comp/static/style.js +0 -0
  36. {jl_db_comp-0.1.0.dist-info → jl_db_comp-0.1.1.dist-info}/WHEEL +0 -0
  37. {jl_db_comp-0.1.0.dist-info → jl_db_comp-0.1.1.dist-info}/licenses/LICENSE +0 -0
jl_db_comp/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # This file is auto-generated by Hatchling. As such, do not:
2
2
  # - modify
3
3
  # - track in version control e.g. be sure to add to .gitignore
4
- __version__ = VERSION = '0.1.0'
4
+ __version__ = VERSION = '0.1.1'
@@ -0,0 +1,173 @@
1
+ """
2
+ Connection management module for jupysql connections.ini integration.
3
+
4
+ This module handles reading database connection configurations from jupysql's
5
+ connections.ini file format and building connection URLs from them.
6
+ """
7
+
8
+ import configparser
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ from urllib.parse import quote_plus
13
+
14
+
15
+ # Default locations to search for connections.ini
16
+ DEFAULT_CONNECTIONS_PATHS = [
17
+ Path.home() / '.jupysql' / 'connections.ini', # jupysql default
18
+ Path.cwd() / 'connections.ini', # Current working directory
19
+ ]
20
+
21
+
22
+ def find_connections_file(custom_path: Optional[str] = None) -> Optional[Path]:
23
+ """Find the connections.ini file.
24
+
25
+ Args:
26
+ custom_path: Optional custom path to connections.ini file
27
+
28
+ Returns:
29
+ Path to the connections.ini file, or None if not found
30
+ """
31
+ # Check custom path first
32
+ if custom_path:
33
+ custom = Path(custom_path)
34
+ if custom.exists():
35
+ return custom
36
+
37
+ # Check default locations
38
+ for path in DEFAULT_CONNECTIONS_PATHS:
39
+ if path.exists():
40
+ return path
41
+
42
+ return None
43
+
44
+
45
+ def parse_connections_file(file_path: Path) -> dict[str, dict[str, str]]:
46
+ """Parse connections.ini file into a dictionary.
47
+
48
+ Args:
49
+ file_path: Path to the connections.ini file
50
+
51
+ Returns:
52
+ Dictionary mapping connection names to their configuration
53
+ """
54
+ config = configparser.ConfigParser()
55
+ config.read(file_path)
56
+
57
+ connections = {}
58
+ for section in config.sections():
59
+ connections[section] = dict(config[section])
60
+
61
+ return connections
62
+
63
+
64
+ def build_connection_url(connection_config: dict[str, str]) -> str:
65
+ """Build a database URL from connection configuration.
66
+
67
+ Args:
68
+ connection_config: Dictionary with connection parameters:
69
+ - drivername: database backend (e.g., 'postgresql')
70
+ - username: database user
71
+ - password: database password
72
+ - host: database host
73
+ - port: database port
74
+ - database: database name
75
+ - query: optional query parameters (as string or dict)
76
+
77
+ Returns:
78
+ Connection URL string
79
+ """
80
+ drivername = connection_config.get('drivername', 'postgresql')
81
+ username = connection_config.get('username', '')
82
+ password = connection_config.get('password', '')
83
+ host = connection_config.get('host', 'localhost')
84
+ port = connection_config.get('port', '5432')
85
+ database = connection_config.get('database', '')
86
+
87
+ # URL encode username and password for special characters
88
+ if username:
89
+ username = quote_plus(username)
90
+ if password:
91
+ password = quote_plus(password)
92
+
93
+ # Build the URL
94
+ if username and password:
95
+ auth = f'{username}:{password}@'
96
+ elif username:
97
+ auth = f'{username}@'
98
+ else:
99
+ auth = ''
100
+
101
+ url = f'{drivername}://{auth}{host}:{port}/{database}'
102
+
103
+ # Handle query parameters if present
104
+ query = connection_config.get('query', '')
105
+ if query:
106
+ # Query could be a string representation of a dict or a plain string
107
+ if isinstance(query, str) and query.startswith('{'):
108
+ # Try to parse as dict-like string
109
+ try:
110
+ import ast
111
+ query_dict = ast.literal_eval(query)
112
+ if isinstance(query_dict, dict):
113
+ query_str = '&'.join(
114
+ f'{k}={v}' for k, v in query_dict.items()
115
+ )
116
+ url = f'{url}?{query_str}'
117
+ except (ValueError, SyntaxError):
118
+ pass
119
+ elif isinstance(query, str) and query:
120
+ url = f'{url}?{query}'
121
+
122
+ return url
123
+
124
+
125
+ def get_connection_url(
126
+ connection_name: str,
127
+ connections_file_path: Optional[str] = None
128
+ ) -> Optional[str]:
129
+ """Get a connection URL by name from connections.ini.
130
+
131
+ Args:
132
+ connection_name: Name of the connection (section name in ini file)
133
+ connections_file_path: Optional custom path to connections.ini
134
+
135
+ Returns:
136
+ Connection URL string, or None if connection not found
137
+ """
138
+ file_path = find_connections_file(connections_file_path)
139
+ if not file_path:
140
+ return None
141
+
142
+ connections = parse_connections_file(file_path)
143
+ if connection_name not in connections:
144
+ return None
145
+
146
+ return build_connection_url(connections[connection_name])
147
+
148
+
149
+ def list_connections(
150
+ connections_file_path: Optional[str] = None
151
+ ) -> dict[str, dict]:
152
+ """List all available connections from connections.ini.
153
+
154
+ Args:
155
+ connections_file_path: Optional custom path to connections.ini
156
+
157
+ Returns:
158
+ Dictionary mapping connection names to their config (without password)
159
+ """
160
+ file_path = find_connections_file(connections_file_path)
161
+ if not file_path:
162
+ return {}
163
+
164
+ connections = parse_connections_file(file_path)
165
+
166
+ # Return connections without exposing passwords
167
+ safe_connections = {}
168
+ for name, config in connections.items():
169
+ safe_config = {k: v for k, v in config.items() if k != 'password'}
170
+ safe_config['has_password'] = 'password' in config
171
+ safe_connections[name] = safe_config
172
+
173
+ return safe_connections
jl_db_comp/routes.py CHANGED
@@ -1,11 +1,16 @@
1
1
  import json
2
- import os
3
2
  from urllib.parse import unquote
4
3
 
5
4
  from jupyter_server.base.handlers import APIHandler
6
5
  from jupyter_server.utils import url_path_join
7
6
  import tornado
8
7
 
8
+ from .connections import (
9
+ find_connections_file,
10
+ get_connection_url,
11
+ list_connections,
12
+ )
13
+
9
14
  try:
10
15
  import psycopg2
11
16
  PSYCOPG2_AVAILABLE = True
@@ -21,7 +26,8 @@ class PostgresCompletionsHandler(APIHandler):
21
26
  """Fetch completions from PostgreSQL database.
22
27
 
23
28
  Query parameters:
24
- - db_url: URL-encoded PostgreSQL connection string
29
+ - connection: Connection name from connections.ini (preferred)
30
+ - db_url: URL-encoded PostgreSQL connection string (fallback)
25
31
  - prefix: Optional prefix to filter results
26
32
  - schema: Database schema (default: 'public')
27
33
  - table: Optional table name to filter columns (only returns columns from this table)
@@ -36,6 +42,8 @@ class PostgresCompletionsHandler(APIHandler):
36
42
  return
37
43
 
38
44
  try:
45
+ connection_name = self.get_argument('connection', None)
46
+ connections_file = self.get_argument('connections_file', None)
39
47
  db_url = self.get_argument('db_url', None)
40
48
  prefix = self.get_argument('prefix', '').lower()
41
49
  schema = self.get_argument('schema', 'public')
@@ -44,9 +52,20 @@ class PostgresCompletionsHandler(APIHandler):
44
52
  jsonb_column = self.get_argument('jsonb_column', None)
45
53
  jsonb_path_str = self.get_argument('jsonb_path', None)
46
54
 
47
- if not db_url:
48
- db_url = os.environ.get('POSTGRES_URL')
49
- else:
55
+ # Priority: connection name -> db_url parameter
56
+ if connection_name:
57
+ db_url = get_connection_url(connection_name, connections_file)
58
+ if not db_url:
59
+ file_info = f" (searched in: {connections_file})" if connections_file else ""
60
+ self.finish(json.dumps({
61
+ "status": "error",
62
+ "tables": [],
63
+ "columns": [],
64
+ "jsonbKeys": [],
65
+ "message": f"Connection '{connection_name}' not found in connections.ini{file_info}"
66
+ }))
67
+ return
68
+ elif db_url:
50
69
  db_url = unquote(db_url)
51
70
 
52
71
  if not db_url:
@@ -55,7 +74,7 @@ class PostgresCompletionsHandler(APIHandler):
55
74
  "tables": [],
56
75
  "columns": [],
57
76
  "jsonbKeys": [],
58
- "message": "No database URL provided"
77
+ "message": "No connection specified. Configure a connection in connections.ini or provide connection name."
59
78
  }))
60
79
  return
61
80
 
@@ -276,9 +295,18 @@ class PostgresCompletionsHandler(APIHandler):
276
295
 
277
296
  result = cursor.fetchone()
278
297
  if not result:
298
+ self.log.warning(
299
+ f"JSONB completion: No JSONB column '{jsonb_column}' found "
300
+ f"in schema '{schema}'. Verify the column exists and has "
301
+ f"data_type='jsonb'."
302
+ )
279
303
  return []
280
304
 
281
305
  table_name = result[0]
306
+ self.log.info(
307
+ f"JSONB completion: Found column '{jsonb_column}' in "
308
+ f"table '{schema}.{table_name}'"
309
+ )
282
310
 
283
311
  # Build the JSONB path expression
284
312
  if jsonb_path and len(jsonb_path) > 0:
@@ -290,6 +318,44 @@ class PostgresCompletionsHandler(APIHandler):
290
318
  # For top-level keys: just the column
291
319
  path_expr = jsonb_column
292
320
 
321
+ # First, check the data distribution at this path for diagnostics
322
+ diag_query = f"""
323
+ SELECT
324
+ COUNT(*) as total_rows,
325
+ COUNT({path_expr}) as non_null_count,
326
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'object' THEN 1 END) as object_count,
327
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'array' THEN 1 END) as array_count,
328
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) IN ('string', 'number', 'boolean') THEN 1 END) as scalar_count
329
+ FROM {schema}.{table_name}
330
+ LIMIT 1000
331
+ """
332
+ cursor.execute(diag_query)
333
+ diag = cursor.fetchone()
334
+
335
+ total_rows, non_null, obj_count, arr_count, scalar_count = diag
336
+
337
+ if non_null == 0:
338
+ self.log.warning(
339
+ f"JSONB completion: Column '{jsonb_column}' in "
340
+ f"'{schema}.{table_name}' has no non-NULL values at "
341
+ f"path '{path_expr}'. Keys cannot be extracted from NULL data."
342
+ )
343
+ return []
344
+
345
+ if obj_count == 0:
346
+ type_info = []
347
+ if arr_count > 0:
348
+ type_info.append(f"{arr_count} arrays")
349
+ if scalar_count > 0:
350
+ type_info.append(f"{scalar_count} scalars")
351
+ self.log.warning(
352
+ f"JSONB completion: Path '{path_expr}' in "
353
+ f"'{schema}.{table_name}' contains no JSON objects "
354
+ f"(found: {', '.join(type_info) if type_info else 'only NULL'}). "
355
+ f"Keys can only be extracted from object types."
356
+ )
357
+ return []
358
+
293
359
  # Query to extract unique keys
294
360
  # LIMIT to 1000 rows for performance (sample the table)
295
361
  query = f"""
@@ -303,6 +369,14 @@ class PostgresCompletionsHandler(APIHandler):
303
369
  cursor.execute(query)
304
370
  keys = cursor.fetchall()
305
371
 
372
+ if len(keys) == 0:
373
+ self.log.warning(
374
+ f"JSONB completion: No keys found at path '{path_expr}' in "
375
+ f"'{schema}.{table_name}' despite {obj_count} objects. "
376
+ f"Objects may be empty {{}}."
377
+ )
378
+ return []
379
+
306
380
  # Filter by prefix and format results
307
381
  result = []
308
382
  for row in keys:
@@ -314,6 +388,11 @@ class PostgresCompletionsHandler(APIHandler):
314
388
  "keyPath": (jsonb_path or []) + [key]
315
389
  })
316
390
 
391
+ self.log.info(
392
+ f"JSONB completion: Found {len(keys)} unique keys at '{path_expr}' "
393
+ f"in '{schema}.{table_name}' (sampled {obj_count} objects)"
394
+ )
395
+
317
396
  return result
318
397
 
319
398
  except psycopg2.Error as e:
@@ -321,12 +400,279 @@ class PostgresCompletionsHandler(APIHandler):
321
400
  return []
322
401
 
323
402
 
403
+ class JsonbDiagnosticsHandler(APIHandler):
404
+ """Handler for diagnosing JSONB column issues."""
405
+
406
+ @tornado.web.authenticated
407
+ def get(self):
408
+ """Get diagnostic information about JSONB columns.
409
+
410
+ Query parameters:
411
+ - connection: Connection name from connections.ini (preferred)
412
+ - db_url: URL-encoded PostgreSQL connection string (fallback)
413
+ - schema: Database schema (default: 'public')
414
+ - table: Optional table name to check
415
+ - column: Optional JSONB column name to check
416
+ - jsonb_path: Optional JSON-encoded path array for nested diagnostics
417
+ """
418
+ if not PSYCOPG2_AVAILABLE:
419
+ self.set_status(500)
420
+ self.finish(json.dumps({
421
+ "status": "error",
422
+ "message": "psycopg2 is not installed"
423
+ }))
424
+ return
425
+
426
+ try:
427
+ connection_name = self.get_argument('connection', None)
428
+ db_url = self.get_argument('db_url', None)
429
+ schema = self.get_argument('schema', 'public')
430
+ table = self.get_argument('table', None)
431
+ column = self.get_argument('column', None)
432
+ jsonb_path_str = self.get_argument('jsonb_path', None)
433
+
434
+ # Priority: connection name -> db_url parameter
435
+ if connection_name:
436
+ db_url = get_connection_url(connection_name)
437
+ if not db_url:
438
+ self.finish(json.dumps({
439
+ "status": "error",
440
+ "message": f"Connection '{connection_name}' not found in connections.ini"
441
+ }))
442
+ return
443
+ elif db_url:
444
+ db_url = unquote(db_url)
445
+
446
+ if not db_url:
447
+ self.finish(json.dumps({
448
+ "status": "error",
449
+ "message": "No connection specified. Configure a connection in connections.ini."
450
+ }))
451
+ return
452
+
453
+ jsonb_path = None
454
+ if jsonb_path_str:
455
+ try:
456
+ jsonb_path = json.loads(jsonb_path_str)
457
+ except json.JSONDecodeError:
458
+ jsonb_path = []
459
+
460
+ diagnostics = self._get_diagnostics(
461
+ db_url, schema, table, column, jsonb_path
462
+ )
463
+ self.finish(json.dumps(diagnostics))
464
+
465
+ except psycopg2.Error as e:
466
+ error_msg = str(e).split('\n')[0]
467
+ self.log.error(f"JSONB diagnostics error: {error_msg}")
468
+ self.set_status(500)
469
+ self.finish(json.dumps({
470
+ "status": "error",
471
+ "message": f"Database error: {error_msg}"
472
+ }))
473
+ except Exception as e:
474
+ error_msg = str(e)
475
+ self.log.error(f"JSONB diagnostics error: {error_msg}")
476
+ self.set_status(500)
477
+ self.finish(json.dumps({
478
+ "status": "error",
479
+ "message": f"Server error: {error_msg}"
480
+ }))
481
+
482
+ def _get_diagnostics(
483
+ self,
484
+ db_url: str,
485
+ schema: str,
486
+ table: str = None,
487
+ column: str = None,
488
+ jsonb_path: list = None
489
+ ) -> dict:
490
+ """Get diagnostic information about JSONB columns."""
491
+ conn = None
492
+ try:
493
+ conn = psycopg2.connect(db_url)
494
+ cursor = conn.cursor()
495
+
496
+ result = {
497
+ "status": "success",
498
+ "schema": schema,
499
+ "jsonbColumns": [],
500
+ "columnDiagnostics": None
501
+ }
502
+
503
+ # Find all JSONB columns in the schema
504
+ query_params = [schema]
505
+ query = """
506
+ SELECT table_name, column_name
507
+ FROM information_schema.columns
508
+ WHERE table_schema = %s
509
+ AND data_type = 'jsonb'
510
+ """
511
+ if table:
512
+ query += " AND LOWER(table_name) = %s"
513
+ query_params.append(table.lower())
514
+ if column:
515
+ query += " AND LOWER(column_name) = %s"
516
+ query_params.append(column.lower())
517
+
518
+ query += " ORDER BY table_name, column_name"
519
+
520
+ cursor.execute(query, query_params)
521
+ jsonb_columns = cursor.fetchall()
522
+
523
+ result["jsonbColumns"] = [
524
+ {"table": row[0], "column": row[1]}
525
+ for row in jsonb_columns
526
+ ]
527
+
528
+ # If specific table and column provided, get detailed diagnostics
529
+ if table and column and len(jsonb_columns) > 0:
530
+ actual_table = jsonb_columns[0][0]
531
+ actual_column = jsonb_columns[0][1]
532
+
533
+ # Build path expression
534
+ if jsonb_path and len(jsonb_path) > 0:
535
+ path_expr = actual_column
536
+ for key in jsonb_path:
537
+ path_expr = f"{path_expr}->'{key}'"
538
+ else:
539
+ path_expr = actual_column
540
+
541
+ # Get type distribution
542
+ diag_query = f"""
543
+ SELECT
544
+ COUNT(*) as total_rows,
545
+ COUNT({path_expr}) as non_null_count,
546
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'object' THEN 1 END) as object_count,
547
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'array' THEN 1 END) as array_count,
548
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'string' THEN 1 END) as string_count,
549
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'number' THEN 1 END) as number_count,
550
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'boolean' THEN 1 END) as boolean_count,
551
+ COUNT(CASE WHEN jsonb_typeof({path_expr}) = 'null' THEN 1 END) as json_null_count
552
+ FROM {schema}.{actual_table}
553
+ """
554
+ cursor.execute(diag_query)
555
+ diag = cursor.fetchone()
556
+
557
+ result["columnDiagnostics"] = {
558
+ "table": actual_table,
559
+ "column": actual_column,
560
+ "pathExpression": path_expr,
561
+ "totalRows": diag[0],
562
+ "nonNullCount": diag[1],
563
+ "typeDistribution": {
564
+ "object": diag[2],
565
+ "array": diag[3],
566
+ "string": diag[4],
567
+ "number": diag[5],
568
+ "boolean": diag[6],
569
+ "null": diag[7]
570
+ },
571
+ "canExtractKeys": diag[2] > 0,
572
+ "recommendation": self._get_recommendation(diag)
573
+ }
574
+
575
+ # If there are objects, get sample keys
576
+ if diag[2] > 0:
577
+ try:
578
+ key_query = f"""
579
+ SELECT DISTINCT jsonb_object_keys({path_expr})
580
+ FROM {schema}.{actual_table}
581
+ WHERE {path_expr} IS NOT NULL
582
+ AND jsonb_typeof({path_expr}) = 'object'
583
+ LIMIT 20
584
+ """
585
+ cursor.execute(key_query)
586
+ keys = [row[0] for row in cursor.fetchall()]
587
+ result["columnDiagnostics"]["sampleKeys"] = keys
588
+ except psycopg2.Error:
589
+ result["columnDiagnostics"]["sampleKeys"] = []
590
+
591
+ cursor.close()
592
+ return result
593
+
594
+ finally:
595
+ if conn:
596
+ conn.close()
597
+
598
+ def _get_recommendation(self, diag) -> str:
599
+ """Generate a recommendation based on diagnostic data."""
600
+ total, non_null, obj, arr, string, number, boolean, json_null = diag
601
+
602
+ if non_null == 0:
603
+ return (
604
+ "All values are NULL. JSONB autocompletion requires non-NULL data. "
605
+ "Check that the column contains actual JSON data."
606
+ )
607
+
608
+ if obj == 0:
609
+ types_found = []
610
+ if arr > 0:
611
+ types_found.append(f"{arr} arrays")
612
+ if string > 0:
613
+ types_found.append(f"{string} strings")
614
+ if number > 0:
615
+ types_found.append(f"{number} numbers")
616
+ if boolean > 0:
617
+ types_found.append(f"{boolean} booleans")
618
+ if json_null > 0:
619
+ types_found.append(f"{json_null} JSON nulls")
620
+
621
+ return (
622
+ f"No JSON objects found. Found: {', '.join(types_found)}. "
623
+ f"JSONB key extraction only works with object types ({{}}). "
624
+ f"If your data contains arrays, you may need to navigate into "
625
+ f"array elements first."
626
+ )
627
+
628
+ return f"JSONB autocompletion should work. Found {obj} objects with extractable keys."
629
+
630
+
631
+ class ConnectionsHandler(APIHandler):
632
+ """Handler for listing available database connections."""
633
+
634
+ @tornado.web.authenticated
635
+ def get(self):
636
+ """List available connections from connections.ini.
637
+
638
+ Returns:
639
+ JSON response with:
640
+ - connections: Dictionary of available connections (without passwords)
641
+ - file_path: Path to the connections.ini file found
642
+ """
643
+ try:
644
+ connections = list_connections()
645
+ file_path = find_connections_file()
646
+
647
+ self.finish(json.dumps({
648
+ "status": "success",
649
+ "connections": connections,
650
+ "filePath": str(file_path) if file_path else None
651
+ }))
652
+
653
+ except Exception as e:
654
+ self.log.error(f"Error listing connections: {e}")
655
+ self.set_status(500)
656
+ self.finish(json.dumps({
657
+ "status": "error",
658
+ "message": f"Error reading connections: {str(e)}",
659
+ "connections": {}
660
+ }))
661
+
662
+
324
663
  def setup_route_handlers(web_app):
325
664
  """Register route handlers with the Jupyter server."""
326
665
  host_pattern = ".*$"
327
666
  base_url = web_app.settings["base_url"]
328
667
 
329
668
  completions_route = url_path_join(base_url, "jl-db-comp", "completions")
330
- handlers = [(completions_route, PostgresCompletionsHandler)]
669
+ diagnostics_route = url_path_join(base_url, "jl-db-comp", "jsonb-diagnostics")
670
+ connections_route = url_path_join(base_url, "jl-db-comp", "connections")
671
+
672
+ handlers = [
673
+ (completions_route, PostgresCompletionsHandler),
674
+ (diagnostics_route, JsonbDiagnosticsHandler),
675
+ (connections_route, ConnectionsHandler),
676
+ ]
331
677
 
332
678
  web_app.add_handlers(host_pattern, handlers)
@@ -15,7 +15,7 @@ async def test_completions_no_db_url(jp_fetch):
15
15
  assert payload["status"] == "success"
16
16
  assert payload["tables"] == []
17
17
  assert payload["columns"] == []
18
- assert "No database URL provided" in payload.get("message", "")
18
+ assert "No connection specified" in payload.get("message", "")
19
19
 
20
20
 
21
21
  async def test_completions_with_invalid_db_url(jp_fetch):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jl_db_comp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A JupyterLab extension to complete db queries in jupyterlab notebooks",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -60,6 +60,7 @@
60
60
  "@jupyterlab/application": "^4.0.0",
61
61
  "@jupyterlab/completer": "^4.0.0",
62
62
  "@jupyterlab/coreutils": "^6.0.0",
63
+ "@jupyterlab/notebook": "^4.0.0",
63
64
  "@jupyterlab/services": "^7.0.0",
64
65
  "@jupyterlab/settingregistry": "^4.0.0"
65
66
  },
@@ -117,7 +118,7 @@
117
118
  "outputDir": "jl_db_comp/labextension",
118
119
  "schemaDir": "schema",
119
120
  "_build": {
120
- "load": "static/remoteEntry.5763ae02737e035e938c.js",
121
+ "load": "static/remoteEntry.48c365d3de0d860be537.js",
121
122
  "extension": "./extension",
122
123
  "style": "./style"
123
124
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jl_db_comp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A JupyterLab extension to complete db queries in jupyterlab notebooks",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -60,6 +60,7 @@
60
60
  "@jupyterlab/application": "^4.0.0",
61
61
  "@jupyterlab/completer": "^4.0.0",
62
62
  "@jupyterlab/coreutils": "^6.0.0",
63
+ "@jupyterlab/notebook": "^4.0.0",
63
64
  "@jupyterlab/services": "^7.0.0",
64
65
  "@jupyterlab/settingregistry": "^4.0.0"
65
66
  },