fastapi-sqlite-ui 1.0.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.
- fastapi_sqlite_ui/__init__.py +16 -0
- fastapi_sqlite_ui/driver.py +174 -0
- fastapi_sqlite_ui/router.py +120 -0
- fastapi_sqlite_ui/ui.py +1131 -0
- fastapi_sqlite_ui-1.0.0.dist-info/METADATA +106 -0
- fastapi_sqlite_ui-1.0.0.dist-info/RECORD +8 -0
- fastapi_sqlite_ui-1.0.0.dist-info/WHEEL +4 -0
- fastapi_sqlite_ui-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .router import sqlite_ui_router
|
|
2
|
+
|
|
3
|
+
def mount_sqlite_ui(app, db_path: str, mount_path: str = "/admin", read_only: bool = False):
|
|
4
|
+
"""
|
|
5
|
+
Mount the SQLite Admin UI APIRouter into your FastAPI application.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
app: The FastAPI application instance.
|
|
9
|
+
db_path: Path to the SQLite database file.
|
|
10
|
+
mount_path: URL prefix under which the UI and API will be mounted (default: "/admin").
|
|
11
|
+
read_only: If True, hides mutation actions (add/edit/delete) and blocks write queries.
|
|
12
|
+
"""
|
|
13
|
+
router = sqlite_ui_router(db_path, read_only)
|
|
14
|
+
app.include_router(router, prefix=mount_path)
|
|
15
|
+
|
|
16
|
+
__all__ = ["sqlite_ui_router", "mount_sqlite_ui"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
class SQLiteDriver:
|
|
5
|
+
def __init__(self, db_path: str, read_only: bool = False):
|
|
6
|
+
self.db_path = db_path
|
|
7
|
+
self.read_only = read_only
|
|
8
|
+
|
|
9
|
+
def _get_connection(self):
|
|
10
|
+
conn = sqlite3.connect(self.db_path)
|
|
11
|
+
conn.row_factory = sqlite3.Row
|
|
12
|
+
return conn
|
|
13
|
+
|
|
14
|
+
def _serialize_value(self, val):
|
|
15
|
+
if isinstance(val, bytes):
|
|
16
|
+
return f"<BLOB: {len(val)} bytes>"
|
|
17
|
+
return val
|
|
18
|
+
|
|
19
|
+
def execute(self, sql: str, params: tuple = ()) -> dict:
|
|
20
|
+
if self.read_only:
|
|
21
|
+
# Check if write statement
|
|
22
|
+
is_write = not re.match(r'^\s*(select|pragma|explain|show|desc)', sql, re.IGNORECASE)
|
|
23
|
+
if is_write:
|
|
24
|
+
return {"rows": [], "columns": [], "error": "Database is in read-only mode"}
|
|
25
|
+
|
|
26
|
+
conn = self._get_connection()
|
|
27
|
+
cursor = conn.cursor()
|
|
28
|
+
try:
|
|
29
|
+
cursor.execute(sql, params)
|
|
30
|
+
|
|
31
|
+
# Check if query returned rows (e.g. SELECT)
|
|
32
|
+
rows_data = []
|
|
33
|
+
columns = []
|
|
34
|
+
affected_rows = None
|
|
35
|
+
|
|
36
|
+
if cursor.description:
|
|
37
|
+
columns = [desc[0] for desc in cursor.description]
|
|
38
|
+
rows = cursor.fetchall()
|
|
39
|
+
rows_data = [
|
|
40
|
+
{col: self._serialize_value(row[col]) for col in columns}
|
|
41
|
+
for row in rows
|
|
42
|
+
]
|
|
43
|
+
else:
|
|
44
|
+
conn.commit()
|
|
45
|
+
affected_rows = cursor.rowcount
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"rows": rows_data,
|
|
49
|
+
"columns": columns,
|
|
50
|
+
"affectedRows": affected_rows
|
|
51
|
+
}
|
|
52
|
+
except sqlite3.Error as e:
|
|
53
|
+
return {"rows": [], "columns": [], "error": str(e)}
|
|
54
|
+
finally:
|
|
55
|
+
conn.close()
|
|
56
|
+
|
|
57
|
+
def get_tables(self) -> list:
|
|
58
|
+
res = self.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
59
|
+
if "error" in res:
|
|
60
|
+
raise Exception(res["error"])
|
|
61
|
+
return [row["name"] for row in res["rows"]]
|
|
62
|
+
|
|
63
|
+
def get_table_info(self, table: str) -> dict:
|
|
64
|
+
sanitized_table = re.sub(r'[^a-zA-Z0-9_]', '', table)
|
|
65
|
+
|
|
66
|
+
col_res = self.execute(f'PRAGMA table_info("{sanitized_table}")')
|
|
67
|
+
if "error" in col_res:
|
|
68
|
+
raise Exception(col_res["error"])
|
|
69
|
+
|
|
70
|
+
fk_res = self.execute(f'PRAGMA foreign_key_list("{sanitized_table}")')
|
|
71
|
+
if "error" in fk_res:
|
|
72
|
+
raise Exception(fk_res["error"])
|
|
73
|
+
|
|
74
|
+
columns = []
|
|
75
|
+
for r in col_res["rows"]:
|
|
76
|
+
columns.append({
|
|
77
|
+
"name": r["name"],
|
|
78
|
+
"type": r["type"],
|
|
79
|
+
"notNull": r["notnull"] == 1,
|
|
80
|
+
"defaultValue": r["dflt_value"],
|
|
81
|
+
"primaryKey": r["pk"] == 1 or r["pk"] is True
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
foreign_keys = []
|
|
85
|
+
for r in fk_res["rows"]:
|
|
86
|
+
foreign_keys.append({
|
|
87
|
+
"id": r["id"],
|
|
88
|
+
"seq": r["seq"],
|
|
89
|
+
"table": r["table"],
|
|
90
|
+
"from": r["from"],
|
|
91
|
+
"to": r["to"],
|
|
92
|
+
"onUpdate": r["on_update"],
|
|
93
|
+
"onDelete": r["on_delete"],
|
|
94
|
+
"match": r["match"]
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"name": table,
|
|
99
|
+
"columns": columns,
|
|
100
|
+
"foreignKeys": foreign_keys
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def get_rows(self, table: str, limit: int, offset: int, search: str = "") -> dict:
|
|
104
|
+
sanitized_table = re.sub(r'[^a-zA-Z0-9_]', '', table)
|
|
105
|
+
info = self.get_table_info(table)
|
|
106
|
+
|
|
107
|
+
where_clause = ""
|
|
108
|
+
params = []
|
|
109
|
+
|
|
110
|
+
if search and info["columns"]:
|
|
111
|
+
# Find columns that are of type text
|
|
112
|
+
search_terms = []
|
|
113
|
+
for c in info["columns"]:
|
|
114
|
+
c_type = c["type"].upper()
|
|
115
|
+
if any(t in c_type for t in ['TEXT', 'VARCHAR', 'CHAR', 'CLOB']) or c_type == '':
|
|
116
|
+
search_terms.append(f'"{c["name"].replace(chr(34), chr(34)+chr(34))}" LIKE ?')
|
|
117
|
+
|
|
118
|
+
if search_terms:
|
|
119
|
+
where_clause = f" WHERE {' OR '.join(search_terms)}"
|
|
120
|
+
wild_card_search = f"%{search}%"
|
|
121
|
+
for _ in search_terms:
|
|
122
|
+
params.append(wild_card_search)
|
|
123
|
+
|
|
124
|
+
# Count total rows
|
|
125
|
+
count_sql = f'SELECT COUNT(*) as count FROM "{sanitized_table}"{where_clause}'
|
|
126
|
+
count_res = self.execute(count_sql, tuple(params))
|
|
127
|
+
total = count_res["rows"][0]["count"] if count_res["rows"] else 0
|
|
128
|
+
|
|
129
|
+
# Fetch rows
|
|
130
|
+
select_sql = f'SELECT * FROM "{sanitized_table}"{where_clause} LIMIT ? OFFSET ?'
|
|
131
|
+
select_res = self.execute(select_sql, tuple(params + [limit, offset]))
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"rows": select_res["rows"],
|
|
135
|
+
"total": total
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def insert_row(self, table: str, data: dict) -> dict:
|
|
139
|
+
sanitized_table = re.sub(r'[^a-zA-Z0-9_]', '', table)
|
|
140
|
+
columns = [f'"{c.replace(chr(34), chr(34)+chr(34))}"' for c in data.keys()]
|
|
141
|
+
placeholders = [', '.join(['?'] * len(data))]
|
|
142
|
+
values = list(data.values())
|
|
143
|
+
|
|
144
|
+
sql = f'INSERT INTO "{sanitized_table}" ({", ".join(columns)}) VALUES ({", ".join(placeholders)})'
|
|
145
|
+
res = self.execute(sql, tuple(values))
|
|
146
|
+
if "error" in res:
|
|
147
|
+
raise Exception(res["error"])
|
|
148
|
+
return res
|
|
149
|
+
|
|
150
|
+
def update_row(self, table: str, pk: dict, data: dict) -> dict:
|
|
151
|
+
sanitized_table = re.sub(r'[^a-zA-Z0-9_]', '', table)
|
|
152
|
+
|
|
153
|
+
set_terms = [f'"{c.replace(chr(34), chr(34)+chr(34))}" = ?' for c in data.keys()]
|
|
154
|
+
set_values = list(data.values())
|
|
155
|
+
|
|
156
|
+
where_terms = [f'"{c.replace(chr(34), chr(34)+chr(34))}" = ?' for c in pk.keys()]
|
|
157
|
+
where_values = list(pk.values())
|
|
158
|
+
|
|
159
|
+
sql = f'UPDATE "{sanitized_table}" SET {", ".join(set_terms)} WHERE {" AND ".join(where_terms)}'
|
|
160
|
+
res = self.execute(sql, tuple(set_values + where_values))
|
|
161
|
+
if "error" in res:
|
|
162
|
+
raise Exception(res["error"])
|
|
163
|
+
return res
|
|
164
|
+
|
|
165
|
+
def delete_row(self, table: str, pk: dict) -> dict:
|
|
166
|
+
sanitized_table = re.sub(r'[^a-zA-Z0-9_]', '', table)
|
|
167
|
+
where_terms = [f'"{c.replace(chr(34), chr(34)+chr(34))}" = ?' for c in pk.keys()]
|
|
168
|
+
where_values = list(pk.values())
|
|
169
|
+
|
|
170
|
+
sql = f'DELETE FROM "{sanitized_table}" WHERE {" AND ".join(where_terms)}'
|
|
171
|
+
res = self.execute(sql, tuple(where_values))
|
|
172
|
+
if "error" in res:
|
|
173
|
+
raise Exception(res["error"])
|
|
174
|
+
return res
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
from fastapi import APIRouter, Request
|
|
3
|
+
# pyrefly: ignore [missing-import]
|
|
4
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
5
|
+
from .driver import SQLiteDriver
|
|
6
|
+
from .ui import get_html_template
|
|
7
|
+
|
|
8
|
+
def sqlite_ui_router(db_path: str, read_only: bool = False) -> APIRouter:
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
driver = SQLiteDriver(db_path, read_only)
|
|
11
|
+
|
|
12
|
+
@router.get("/", response_class=HTMLResponse)
|
|
13
|
+
def index(request: Request):
|
|
14
|
+
path = request.url.path
|
|
15
|
+
base_path = path[:-1] if path.endswith('/') else path
|
|
16
|
+
return get_html_template(base_path)
|
|
17
|
+
|
|
18
|
+
@router.get("/index.html", response_class=HTMLResponse)
|
|
19
|
+
def index_html(request: Request):
|
|
20
|
+
path = request.url.path
|
|
21
|
+
base_path = path[:-11] if path.endswith('/index.html') else path
|
|
22
|
+
return get_html_template(base_path)
|
|
23
|
+
|
|
24
|
+
@router.get("/api/tables")
|
|
25
|
+
def list_tables():
|
|
26
|
+
try:
|
|
27
|
+
tables = driver.get_tables()
|
|
28
|
+
return {
|
|
29
|
+
"success": True,
|
|
30
|
+
"tables": tables,
|
|
31
|
+
"readOnly": read_only
|
|
32
|
+
}
|
|
33
|
+
except Exception as e:
|
|
34
|
+
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
|
35
|
+
|
|
36
|
+
@router.get("/api/tables/{table_name}")
|
|
37
|
+
def table_data(table_name: str, page: int = 1, limit: int = 10, search: str = ""):
|
|
38
|
+
try:
|
|
39
|
+
offset = (page - 1) * limit
|
|
40
|
+
info = driver.get_table_info(table_name)
|
|
41
|
+
data = driver.get_rows(table_name, limit, offset, search)
|
|
42
|
+
return {
|
|
43
|
+
"success": True,
|
|
44
|
+
"info": info,
|
|
45
|
+
"rows": data["rows"],
|
|
46
|
+
"total": data["total"]
|
|
47
|
+
}
|
|
48
|
+
except Exception as e:
|
|
49
|
+
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
|
50
|
+
|
|
51
|
+
@router.post("/api/query")
|
|
52
|
+
async def run_query(request: Request):
|
|
53
|
+
try:
|
|
54
|
+
body = await request.json()
|
|
55
|
+
sql = body.get("sql")
|
|
56
|
+
params = body.get("params", [])
|
|
57
|
+
|
|
58
|
+
if not sql:
|
|
59
|
+
return JSONResponse(status_code=400, content={"success": False, "error": "SQL query is required"})
|
|
60
|
+
|
|
61
|
+
res = driver.execute(sql, tuple(params))
|
|
62
|
+
if "error" in res:
|
|
63
|
+
return {"success": False, "error": res["error"]}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"success": True,
|
|
67
|
+
"rows": res["rows"],
|
|
68
|
+
"columns": res["columns"],
|
|
69
|
+
"affectedRows": res["affectedRows"]
|
|
70
|
+
}
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
|
73
|
+
|
|
74
|
+
@router.post("/api/tables/{table_name}")
|
|
75
|
+
async def add_row(table_name: str, request: Request):
|
|
76
|
+
if read_only:
|
|
77
|
+
return JSONResponse(status_code=403, content={"success": False, "error": "Database is in read-only mode"})
|
|
78
|
+
try:
|
|
79
|
+
body = await request.json()
|
|
80
|
+
data = body.get("data")
|
|
81
|
+
if not data:
|
|
82
|
+
return JSONResponse(status_code=400, content={"success": False, "error": "Data is required"})
|
|
83
|
+
|
|
84
|
+
driver.insert_row(table_name, data)
|
|
85
|
+
return {"success": True}
|
|
86
|
+
except Exception as e:
|
|
87
|
+
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
|
88
|
+
|
|
89
|
+
@router.put("/api/tables/{table_name}")
|
|
90
|
+
async def edit_row(table_name: str, request: Request):
|
|
91
|
+
if read_only:
|
|
92
|
+
return JSONResponse(status_code=403, content={"success": False, "error": "Database is in read-only mode"})
|
|
93
|
+
try:
|
|
94
|
+
body = await request.json()
|
|
95
|
+
pk = body.get("pk")
|
|
96
|
+
data = body.get("data")
|
|
97
|
+
if not pk or not data:
|
|
98
|
+
return JSONResponse(status_code=400, content={"success": False, "error": "pk and data are required"})
|
|
99
|
+
|
|
100
|
+
driver.update_row(table_name, pk, data)
|
|
101
|
+
return {"success": True}
|
|
102
|
+
except Exception as e:
|
|
103
|
+
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
|
104
|
+
|
|
105
|
+
@router.delete("/api/tables/{table_name}")
|
|
106
|
+
async def delete_row(table_name: str, request: Request):
|
|
107
|
+
if read_only:
|
|
108
|
+
return JSONResponse(status_code=403, content={"success": False, "error": "Database is in read-only mode"})
|
|
109
|
+
try:
|
|
110
|
+
body = await request.json()
|
|
111
|
+
pk = body.get("pk")
|
|
112
|
+
if not pk:
|
|
113
|
+
return JSONResponse(status_code=400, content={"success": False, "error": "pk is required"})
|
|
114
|
+
|
|
115
|
+
driver.delete_row(table_name, pk)
|
|
116
|
+
return {"success": True}
|
|
117
|
+
except Exception as e:
|
|
118
|
+
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
|
119
|
+
|
|
120
|
+
return router
|