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.
@@ -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