tuskdata 0.1.0__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.
- tusk/__init__.py +3 -0
- tusk/admin/__init__.py +42 -0
- tusk/admin/backup.py +331 -0
- tusk/admin/extensions.py +148 -0
- tusk/admin/maintenance.py +216 -0
- tusk/admin/monitoring.py +422 -0
- tusk/admin/pitr.py +487 -0
- tusk/admin/processes.py +87 -0
- tusk/admin/roles.py +276 -0
- tusk/admin/settings.py +185 -0
- tusk/admin/stats.py +73 -0
- tusk/cli.py +521 -0
- tusk/cluster/__init__.py +15 -0
- tusk/cluster/models.py +68 -0
- tusk/cluster/scheduler.py +337 -0
- tusk/cluster/worker.py +241 -0
- tusk/core/__init__.py +62 -0
- tusk/core/auth.py +801 -0
- tusk/core/config.py +163 -0
- tusk/core/connection.py +148 -0
- tusk/core/deps.py +84 -0
- tusk/core/files.py +151 -0
- tusk/core/geo.py +503 -0
- tusk/core/history.py +261 -0
- tusk/core/logging.py +33 -0
- tusk/core/result.py +40 -0
- tusk/core/scheduled_tasks.py +271 -0
- tusk/core/scheduler.py +229 -0
- tusk/core/workspace.py +154 -0
- tusk/engines/__init__.py +6 -0
- tusk/engines/duckdb_engine.py +312 -0
- tusk/engines/polars_engine.py +873 -0
- tusk/engines/postgres.py +191 -0
- tusk/engines/sqlite.py +87 -0
- tusk/studio/__init__.py +1 -0
- tusk/studio/app.py +83 -0
- tusk/studio/routes/__init__.py +28 -0
- tusk/studio/routes/admin.py +765 -0
- tusk/studio/routes/api.py +450 -0
- tusk/studio/routes/auth.py +545 -0
- tusk/studio/routes/cluster.py +446 -0
- tusk/studio/routes/data.py +696 -0
- tusk/studio/routes/files.py +385 -0
- tusk/studio/routes/pages.py +58 -0
- tusk/studio/routes/scheduler.py +170 -0
- tusk/studio/routes/settings.py +97 -0
- tusk/studio/static/.gitkeep +0 -0
- tusk/studio/static/admin.js +1473 -0
- tusk/studio/static/cluster.js +422 -0
- tusk/studio/static/data.js +1973 -0
- tusk/studio/static/profile.js +126 -0
- tusk/studio/static/studio.js +2608 -0
- tusk/studio/static/styles.css +379 -0
- tusk/studio/static/users.js +326 -0
- tusk/studio/templates/admin.html +524 -0
- tusk/studio/templates/base.html +206 -0
- tusk/studio/templates/cluster.html +182 -0
- tusk/studio/templates/data.html +560 -0
- tusk/studio/templates/index.html +294 -0
- tusk/studio/templates/login.html +149 -0
- tusk/studio/templates/profile.html +87 -0
- tusk/studio/templates/users.html +179 -0
- tuskdata-0.1.0.dist-info/METADATA +38 -0
- tuskdata-0.1.0.dist-info/RECORD +66 -0
- tuskdata-0.1.0.dist-info/WHEEL +4 -0
- tuskdata-0.1.0.dist-info/entry_points.txt +2 -0
tusk/__init__.py
ADDED
tusk/admin/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""PostgreSQL administration module"""
|
|
2
|
+
|
|
3
|
+
from tusk.admin.stats import get_server_stats, ServerStats
|
|
4
|
+
from tusk.admin.processes import get_active_queries, kill_query, ActiveQuery
|
|
5
|
+
from tusk.admin.backup import create_backup, list_backups, get_backup_path
|
|
6
|
+
from tusk.admin.extensions import (
|
|
7
|
+
get_extensions,
|
|
8
|
+
get_installed_extensions,
|
|
9
|
+
install_extension,
|
|
10
|
+
uninstall_extension,
|
|
11
|
+
Extension,
|
|
12
|
+
)
|
|
13
|
+
from tusk.admin.maintenance import (
|
|
14
|
+
get_locks,
|
|
15
|
+
get_all_locks,
|
|
16
|
+
get_table_bloat,
|
|
17
|
+
vacuum_table,
|
|
18
|
+
analyze_table,
|
|
19
|
+
reindex_table,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"get_server_stats",
|
|
24
|
+
"ServerStats",
|
|
25
|
+
"get_active_queries",
|
|
26
|
+
"kill_query",
|
|
27
|
+
"ActiveQuery",
|
|
28
|
+
"create_backup",
|
|
29
|
+
"list_backups",
|
|
30
|
+
"get_backup_path",
|
|
31
|
+
"get_extensions",
|
|
32
|
+
"get_installed_extensions",
|
|
33
|
+
"install_extension",
|
|
34
|
+
"uninstall_extension",
|
|
35
|
+
"Extension",
|
|
36
|
+
"get_locks",
|
|
37
|
+
"get_all_locks",
|
|
38
|
+
"get_table_bloat",
|
|
39
|
+
"vacuum_table",
|
|
40
|
+
"analyze_table",
|
|
41
|
+
"reindex_table",
|
|
42
|
+
]
|
tusk/admin/backup.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Backup and restore functionality for PostgreSQL"""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from tusk.core.connection import ConnectionConfig, TUSK_DIR
|
|
9
|
+
from tusk.core.config import get_config
|
|
10
|
+
|
|
11
|
+
BACKUP_DIR = TUSK_DIR / "backups"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_pg_bin_search_paths() -> list[Path]:
|
|
15
|
+
"""Get list of paths to search for PostgreSQL binaries"""
|
|
16
|
+
paths = []
|
|
17
|
+
seen_resolved = set()
|
|
18
|
+
|
|
19
|
+
def add_path(p: Path):
|
|
20
|
+
"""Add path if not already seen (resolves symlinks to avoid duplicates)"""
|
|
21
|
+
try:
|
|
22
|
+
resolved = p.resolve()
|
|
23
|
+
if resolved not in seen_resolved and resolved.exists():
|
|
24
|
+
seen_resolved.add(resolved)
|
|
25
|
+
paths.append(p)
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Postgres.app (macOS) - check latest first
|
|
30
|
+
add_path(Path("/Applications/Postgres.app/Contents/Versions/latest/bin"))
|
|
31
|
+
|
|
32
|
+
# Add versioned Postgres.app paths
|
|
33
|
+
pg_app_versions = Path("/Applications/Postgres.app/Contents/Versions")
|
|
34
|
+
if pg_app_versions.exists():
|
|
35
|
+
for version_dir in sorted(pg_app_versions.iterdir(), reverse=True):
|
|
36
|
+
if version_dir.name != "latest" and version_dir.is_dir():
|
|
37
|
+
add_path(version_dir / "bin")
|
|
38
|
+
|
|
39
|
+
# Homebrew (macOS)
|
|
40
|
+
for p in [
|
|
41
|
+
Path("/opt/homebrew/opt/postgresql/bin"),
|
|
42
|
+
Path("/opt/homebrew/bin"),
|
|
43
|
+
Path("/usr/local/opt/postgresql/bin"),
|
|
44
|
+
Path("/usr/local/bin"),
|
|
45
|
+
]:
|
|
46
|
+
add_path(p)
|
|
47
|
+
|
|
48
|
+
# Linux common paths
|
|
49
|
+
for p in [
|
|
50
|
+
Path("/usr/lib/postgresql/16/bin"),
|
|
51
|
+
Path("/usr/lib/postgresql/15/bin"),
|
|
52
|
+
Path("/usr/lib/postgresql/14/bin"),
|
|
53
|
+
Path("/usr/pgsql-16/bin"),
|
|
54
|
+
Path("/usr/pgsql-15/bin"),
|
|
55
|
+
Path("/usr/bin"),
|
|
56
|
+
]:
|
|
57
|
+
add_path(p)
|
|
58
|
+
|
|
59
|
+
return paths
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _find_pg_binary(name: str) -> str:
|
|
63
|
+
"""Find a PostgreSQL binary (pg_dump, psql, etc.)
|
|
64
|
+
|
|
65
|
+
Priority:
|
|
66
|
+
1. User-configured pg_bin_path in ~/.tusk/config.toml
|
|
67
|
+
2. System PATH
|
|
68
|
+
3. Common installation locations (Postgres.app, Homebrew, etc.)
|
|
69
|
+
"""
|
|
70
|
+
import shutil
|
|
71
|
+
|
|
72
|
+
# First check user-configured path
|
|
73
|
+
config = get_config()
|
|
74
|
+
if config.pg_bin_path:
|
|
75
|
+
configured_path = Path(config.pg_bin_path) / name
|
|
76
|
+
if configured_path.exists() and configured_path.is_file():
|
|
77
|
+
return str(configured_path)
|
|
78
|
+
|
|
79
|
+
# Then check if it's in PATH
|
|
80
|
+
if shutil.which(name):
|
|
81
|
+
return name
|
|
82
|
+
|
|
83
|
+
# Search in common locations
|
|
84
|
+
for search_path in _get_pg_bin_search_paths():
|
|
85
|
+
binary_path = search_path / name
|
|
86
|
+
if binary_path.exists() and binary_path.is_file():
|
|
87
|
+
return str(binary_path)
|
|
88
|
+
|
|
89
|
+
# Fallback to just the name (will fail with clear error if not found)
|
|
90
|
+
return name
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_pg_dump_path() -> str:
|
|
94
|
+
"""Get the path to pg_dump binary"""
|
|
95
|
+
return _find_pg_binary("pg_dump")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_psql_path() -> str:
|
|
99
|
+
"""Get the path to psql binary"""
|
|
100
|
+
return _find_pg_binary("psql")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_backup(config: ConnectionConfig) -> tuple[bool, str, Path | None]:
|
|
104
|
+
"""Create a pg_dump backup of the database
|
|
105
|
+
|
|
106
|
+
Returns: (success, message, filepath)
|
|
107
|
+
"""
|
|
108
|
+
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
111
|
+
filename = f"{config.database}_{timestamp}.sql.gz"
|
|
112
|
+
filepath = BACKUP_DIR / filename
|
|
113
|
+
|
|
114
|
+
pg_dump = get_pg_dump_path()
|
|
115
|
+
|
|
116
|
+
cmd = [
|
|
117
|
+
pg_dump,
|
|
118
|
+
"-h", config.host or "localhost",
|
|
119
|
+
"-p", str(config.port),
|
|
120
|
+
"-U", config.user or "postgres",
|
|
121
|
+
"-d", config.database or "postgres",
|
|
122
|
+
"--format=plain",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
env = os.environ.copy()
|
|
126
|
+
if config.password:
|
|
127
|
+
env["PGPASSWORD"] = config.password
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# pg_dump | gzip > file
|
|
131
|
+
with open(filepath, "wb") as f:
|
|
132
|
+
dump_proc = subprocess.Popen(
|
|
133
|
+
cmd,
|
|
134
|
+
stdout=subprocess.PIPE,
|
|
135
|
+
stderr=subprocess.PIPE,
|
|
136
|
+
env=env
|
|
137
|
+
)
|
|
138
|
+
gzip_proc = subprocess.Popen(
|
|
139
|
+
["gzip"],
|
|
140
|
+
stdin=dump_proc.stdout,
|
|
141
|
+
stdout=f,
|
|
142
|
+
stderr=subprocess.PIPE
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
dump_proc.stdout.close()
|
|
146
|
+
gzip_proc.communicate()
|
|
147
|
+
dump_proc.wait()
|
|
148
|
+
|
|
149
|
+
if dump_proc.returncode != 0:
|
|
150
|
+
stderr = dump_proc.stderr.read().decode() if dump_proc.stderr else "Unknown error"
|
|
151
|
+
filepath.unlink(missing_ok=True)
|
|
152
|
+
return False, f"pg_dump failed: {stderr}", None
|
|
153
|
+
|
|
154
|
+
size = filepath.stat().st_size
|
|
155
|
+
size_human = f"{size / 1024 / 1024:.2f} MB" if size > 1024 * 1024 else f"{size / 1024:.1f} KB"
|
|
156
|
+
return True, f"Backup created: {filename} ({size_human})", filepath
|
|
157
|
+
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
return False, f"pg_dump not found at '{pg_dump}'. Install PostgreSQL client tools or check Postgres.app installation.", None
|
|
160
|
+
except Exception as e:
|
|
161
|
+
filepath.unlink(missing_ok=True)
|
|
162
|
+
return False, f"Backup failed: {str(e)}", None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def list_backups() -> list[dict]:
|
|
166
|
+
"""List all available backups"""
|
|
167
|
+
if not BACKUP_DIR.exists():
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
backups = []
|
|
171
|
+
for f in sorted(BACKUP_DIR.glob("*.sql.gz"), reverse=True):
|
|
172
|
+
stat = f.stat()
|
|
173
|
+
backups.append({
|
|
174
|
+
"filename": f.name,
|
|
175
|
+
"size_bytes": stat.st_size,
|
|
176
|
+
"size_human": f"{stat.st_size / 1024 / 1024:.2f} MB" if stat.st_size > 1024 * 1024 else f"{stat.st_size / 1024:.1f} KB",
|
|
177
|
+
"created": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
return backups
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_backup_path(filename: str) -> Path | None:
|
|
184
|
+
"""Get full path to a backup file (for download)"""
|
|
185
|
+
filepath = BACKUP_DIR / filename
|
|
186
|
+
if filepath.exists() and filepath.is_file():
|
|
187
|
+
return filepath
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def restore_backup(config: ConnectionConfig, filename: str) -> tuple[bool, str]:
|
|
192
|
+
"""Restore a database from backup
|
|
193
|
+
|
|
194
|
+
WARNING: This will overwrite the target database!
|
|
195
|
+
"""
|
|
196
|
+
filepath = get_backup_path(filename)
|
|
197
|
+
if not filepath:
|
|
198
|
+
return False, f"Backup file not found: {filename}"
|
|
199
|
+
|
|
200
|
+
psql = get_psql_path()
|
|
201
|
+
|
|
202
|
+
cmd = [
|
|
203
|
+
psql,
|
|
204
|
+
"-h", config.host or "localhost",
|
|
205
|
+
"-p", str(config.port),
|
|
206
|
+
"-U", config.user or "postgres",
|
|
207
|
+
"-d", config.database or "postgres",
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
env = os.environ.copy()
|
|
211
|
+
if config.password:
|
|
212
|
+
env["PGPASSWORD"] = config.password
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
# gunzip < file | psql
|
|
216
|
+
with open(filepath, "rb") as f:
|
|
217
|
+
gunzip_proc = subprocess.Popen(
|
|
218
|
+
["gunzip", "-c"],
|
|
219
|
+
stdin=f,
|
|
220
|
+
stdout=subprocess.PIPE,
|
|
221
|
+
stderr=subprocess.PIPE
|
|
222
|
+
)
|
|
223
|
+
psql_proc = subprocess.Popen(
|
|
224
|
+
cmd,
|
|
225
|
+
stdin=gunzip_proc.stdout,
|
|
226
|
+
stdout=subprocess.PIPE,
|
|
227
|
+
stderr=subprocess.PIPE,
|
|
228
|
+
env=env
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
gunzip_proc.stdout.close()
|
|
232
|
+
stdout, stderr = psql_proc.communicate()
|
|
233
|
+
|
|
234
|
+
if psql_proc.returncode != 0:
|
|
235
|
+
return False, f"Restore failed: {stderr.decode()}"
|
|
236
|
+
|
|
237
|
+
return True, f"Database restored from {filename}"
|
|
238
|
+
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
return False, f"psql not found at '{psql}'. Install PostgreSQL client tools or check Postgres.app installation."
|
|
241
|
+
except Exception as e:
|
|
242
|
+
return False, f"Restore failed: {str(e)}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_createdb_path() -> str:
|
|
246
|
+
"""Get the path to createdb binary"""
|
|
247
|
+
return _find_pg_binary("createdb")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def create_database(config: ConnectionConfig, db_name: str, owner: str | None = None) -> tuple[bool, str]:
|
|
251
|
+
"""Create a new database on the PostgreSQL server
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
config: Connection config (uses host, port, user, password)
|
|
255
|
+
db_name: Name for the new database
|
|
256
|
+
owner: Optional owner for the database
|
|
257
|
+
"""
|
|
258
|
+
createdb = get_createdb_path()
|
|
259
|
+
|
|
260
|
+
cmd = [
|
|
261
|
+
createdb,
|
|
262
|
+
"-h", config.host or "localhost",
|
|
263
|
+
"-p", str(config.port),
|
|
264
|
+
"-U", config.user or "postgres",
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
if owner:
|
|
268
|
+
cmd.extend(["-O", owner])
|
|
269
|
+
|
|
270
|
+
cmd.append(db_name)
|
|
271
|
+
|
|
272
|
+
env = os.environ.copy()
|
|
273
|
+
if config.password:
|
|
274
|
+
env["PGPASSWORD"] = config.password
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
result = subprocess.run(
|
|
278
|
+
cmd,
|
|
279
|
+
capture_output=True,
|
|
280
|
+
text=True,
|
|
281
|
+
env=env
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if result.returncode != 0:
|
|
285
|
+
return False, f"Create database failed: {result.stderr}"
|
|
286
|
+
|
|
287
|
+
return True, f"Database '{db_name}' created successfully"
|
|
288
|
+
|
|
289
|
+
except FileNotFoundError:
|
|
290
|
+
return False, f"createdb not found at '{createdb}'. Install PostgreSQL client tools."
|
|
291
|
+
except Exception as e:
|
|
292
|
+
return False, f"Create database failed: {str(e)}"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def create_database_from_backup(
|
|
296
|
+
config: ConnectionConfig,
|
|
297
|
+
filename: str,
|
|
298
|
+
new_db_name: str,
|
|
299
|
+
owner: str | None = None
|
|
300
|
+
) -> tuple[bool, str]:
|
|
301
|
+
"""Create a new database and restore from backup
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
config: Connection config for the PostgreSQL server
|
|
305
|
+
filename: Backup file to restore from
|
|
306
|
+
new_db_name: Name for the new database
|
|
307
|
+
owner: Optional owner for the new database
|
|
308
|
+
"""
|
|
309
|
+
# First create the new database
|
|
310
|
+
success, message = create_database(config, new_db_name, owner)
|
|
311
|
+
if not success:
|
|
312
|
+
return False, message
|
|
313
|
+
|
|
314
|
+
# Create a modified config pointing to the new database
|
|
315
|
+
new_config = ConnectionConfig(
|
|
316
|
+
id=config.id,
|
|
317
|
+
name=config.name,
|
|
318
|
+
type=config.type,
|
|
319
|
+
host=config.host,
|
|
320
|
+
port=config.port,
|
|
321
|
+
user=config.user,
|
|
322
|
+
password=config.password,
|
|
323
|
+
database=new_db_name,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Restore the backup to the new database
|
|
327
|
+
success, message = restore_backup(new_config, filename)
|
|
328
|
+
if not success:
|
|
329
|
+
return False, f"Database created but restore failed: {message}"
|
|
330
|
+
|
|
331
|
+
return True, f"Database '{new_db_name}' created and restored from {filename}"
|
tusk/admin/extensions.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""PostgreSQL extension management"""
|
|
2
|
+
|
|
3
|
+
import msgspec
|
|
4
|
+
from ..core.connection import ConnectionConfig
|
|
5
|
+
from ..engines.postgres import execute_query
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Extension(msgspec.Struct):
|
|
9
|
+
"""PostgreSQL extension info"""
|
|
10
|
+
name: str
|
|
11
|
+
installed_version: str | None = None
|
|
12
|
+
default_version: str | None = None
|
|
13
|
+
description: str | None = None
|
|
14
|
+
is_installed: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def get_extensions(config: ConnectionConfig) -> list[Extension]:
|
|
18
|
+
"""Get all available extensions with their installation status"""
|
|
19
|
+
|
|
20
|
+
# Query to get all available extensions and their installation status
|
|
21
|
+
sql = """
|
|
22
|
+
SELECT
|
|
23
|
+
a.name,
|
|
24
|
+
i.extversion as installed_version,
|
|
25
|
+
a.default_version,
|
|
26
|
+
a.comment as description,
|
|
27
|
+
CASE WHEN i.extname IS NOT NULL THEN true ELSE false END as is_installed
|
|
28
|
+
FROM pg_available_extensions a
|
|
29
|
+
LEFT JOIN pg_extension i ON a.name = i.extname
|
|
30
|
+
ORDER BY
|
|
31
|
+
CASE WHEN i.extname IS NOT NULL THEN 0 ELSE 1 END,
|
|
32
|
+
a.name
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
result = await execute_query(config, sql)
|
|
36
|
+
|
|
37
|
+
extensions = []
|
|
38
|
+
for row in result.rows:
|
|
39
|
+
extensions.append(Extension(
|
|
40
|
+
name=row[0],
|
|
41
|
+
installed_version=row[1],
|
|
42
|
+
default_version=row[2],
|
|
43
|
+
description=row[3],
|
|
44
|
+
is_installed=row[4]
|
|
45
|
+
))
|
|
46
|
+
|
|
47
|
+
return extensions
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def get_installed_extensions(config: ConnectionConfig) -> list[Extension]:
|
|
51
|
+
"""Get only installed extensions"""
|
|
52
|
+
|
|
53
|
+
sql = """
|
|
54
|
+
SELECT
|
|
55
|
+
e.extname as name,
|
|
56
|
+
e.extversion as installed_version,
|
|
57
|
+
a.default_version,
|
|
58
|
+
a.comment as description,
|
|
59
|
+
true as is_installed
|
|
60
|
+
FROM pg_extension e
|
|
61
|
+
LEFT JOIN pg_available_extensions a ON e.extname = a.name
|
|
62
|
+
ORDER BY e.extname
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
result = await execute_query(config, sql)
|
|
66
|
+
|
|
67
|
+
extensions = []
|
|
68
|
+
for row in result.rows:
|
|
69
|
+
extensions.append(Extension(
|
|
70
|
+
name=row[0],
|
|
71
|
+
installed_version=row[1],
|
|
72
|
+
default_version=row[2],
|
|
73
|
+
description=row[3],
|
|
74
|
+
is_installed=True
|
|
75
|
+
))
|
|
76
|
+
|
|
77
|
+
return extensions
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def install_extension(config: ConnectionConfig, name: str, schema: str | None = None) -> bool:
|
|
81
|
+
"""Install an extension"""
|
|
82
|
+
|
|
83
|
+
# Validate extension name (prevent SQL injection)
|
|
84
|
+
if not name.replace("_", "").replace("-", "").isalnum():
|
|
85
|
+
raise ValueError(f"Invalid extension name: {name}")
|
|
86
|
+
|
|
87
|
+
sql = f'CREATE EXTENSION IF NOT EXISTS "{name}"'
|
|
88
|
+
if schema:
|
|
89
|
+
# Validate schema name
|
|
90
|
+
if not schema.replace("_", "").isalnum():
|
|
91
|
+
raise ValueError(f"Invalid schema name: {schema}")
|
|
92
|
+
sql += f' SCHEMA "{schema}"'
|
|
93
|
+
|
|
94
|
+
await execute_query(config, sql)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def uninstall_extension(config: ConnectionConfig, name: str, cascade: bool = False) -> bool:
|
|
99
|
+
"""Uninstall an extension"""
|
|
100
|
+
|
|
101
|
+
# Validate extension name (prevent SQL injection)
|
|
102
|
+
if not name.replace("_", "").replace("-", "").isalnum():
|
|
103
|
+
raise ValueError(f"Invalid extension name: {name}")
|
|
104
|
+
|
|
105
|
+
sql = f'DROP EXTENSION IF EXISTS "{name}"'
|
|
106
|
+
if cascade:
|
|
107
|
+
sql += " CASCADE"
|
|
108
|
+
|
|
109
|
+
await execute_query(config, sql)
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def get_extension_details(config: ConnectionConfig, name: str) -> dict:
|
|
114
|
+
"""Get detailed information about an extension"""
|
|
115
|
+
|
|
116
|
+
# Validate extension name
|
|
117
|
+
if not name.replace("_", "").replace("-", "").isalnum():
|
|
118
|
+
raise ValueError(f"Invalid extension name: {name}")
|
|
119
|
+
|
|
120
|
+
sql = f"""
|
|
121
|
+
SELECT
|
|
122
|
+
a.name,
|
|
123
|
+
a.default_version,
|
|
124
|
+
a.comment as description,
|
|
125
|
+
e.extversion as installed_version,
|
|
126
|
+
e.extrelocatable as relocatable,
|
|
127
|
+
n.nspname as schema
|
|
128
|
+
FROM pg_available_extensions a
|
|
129
|
+
LEFT JOIN pg_extension e ON a.name = e.extname
|
|
130
|
+
LEFT JOIN pg_namespace n ON e.extnamespace = n.oid
|
|
131
|
+
WHERE a.name = '{name}'
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
result = await execute_query(config, sql)
|
|
135
|
+
|
|
136
|
+
if not result.rows:
|
|
137
|
+
return {"error": f"Extension '{name}' not found"}
|
|
138
|
+
|
|
139
|
+
row = result.rows[0]
|
|
140
|
+
return {
|
|
141
|
+
"name": row[0],
|
|
142
|
+
"default_version": row[1],
|
|
143
|
+
"description": row[2],
|
|
144
|
+
"installed_version": row[3],
|
|
145
|
+
"relocatable": row[4],
|
|
146
|
+
"schema": row[5],
|
|
147
|
+
"is_installed": row[3] is not None
|
|
148
|
+
}
|