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.
- jl_db_comp/_version.py +1 -1
- jl_db_comp/connections.py +173 -0
- jl_db_comp/routes.py +353 -7
- jl_db_comp/tests/test_routes.py +1 -1
- {jl_db_comp/labextension → jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp}/package.json +3 -2
- {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
- {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
- jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/171.d366980651e0db8d978c.js +1 -0
- jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/728.6552504d5b9b27551bc5.js +1 -0
- jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/remoteEntry.48c365d3de0d860be537.js +1 -0
- jl_db_comp-0.1.1.data/data/share/jupyter/labextensions/jl_db_comp/static/third-party-licenses.json +16 -0
- {jl_db_comp-0.1.0.dist-info → jl_db_comp-0.1.1.dist-info}/METADATA +103 -1
- jl_db_comp-0.1.1.dist-info/RECORD +20 -0
- jl_db_comp/labextension/build_log.json +0 -728
- jl_db_comp/labextension/schemas/jl_db_comp/package.json.orig +0 -214
- jl_db_comp/labextension/schemas/jl_db_comp/plugin.json +0 -27
- jl_db_comp/labextension/static/lib_index_js.a0969ed73da70f2cc451.js +0 -561
- jl_db_comp/labextension/static/lib_index_js.a0969ed73da70f2cc451.js.map +0 -1
- jl_db_comp/labextension/static/remoteEntry.5763ae02737e035e938c.js +0 -560
- jl_db_comp/labextension/static/remoteEntry.5763ae02737e035e938c.js.map +0 -1
- jl_db_comp/labextension/static/style.js +0 -4
- jl_db_comp/labextension/static/style_index_js.5364c7419a6b9db5d727.js +0 -508
- jl_db_comp/labextension/static/style_index_js.5364c7419a6b9db5d727.js.map +0 -1
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/build_log.json +0 -728
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/package.json +0 -219
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/lib_index_js.a0969ed73da70f2cc451.js +0 -561
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/lib_index_js.a0969ed73da70f2cc451.js.map +0 -1
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/remoteEntry.5763ae02737e035e938c.js +0 -560
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/remoteEntry.5763ae02737e035e938c.js.map +0 -1
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/style_index_js.5364c7419a6b9db5d727.js +0 -508
- jl_db_comp-0.1.0.data/data/share/jupyter/labextensions/jl_db_comp/static/style_index_js.5364c7419a6b9db5d727.js.map +0 -1
- jl_db_comp-0.1.0.dist-info/RECORD +0 -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
- {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
- {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
- {jl_db_comp-0.1.0.dist-info → jl_db_comp-0.1.1.dist-info}/WHEEL +0 -0
- {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
|
@@ -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
|
-
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
jl_db_comp/tests/test_routes.py
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
},
|