sqlalchemy-studio 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.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlalchemy-studio
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author-email: Xursand Qarlibayev <coderxuz2009@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/coderxuz/sqlalchemy-studio
8
+ Project-URL: Source, https://github.com/coderxuz/sqlalchemy-studio
9
+ Keywords: sqlalchemy,database,inspector,studio
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: build>=1.5.0
19
+ Requires-Dist: fastapi>=0.136.3
20
+ Requires-Dist: sqlalchemy>=2.0.50
21
+ Requires-Dist: twine>=6.2.0
22
+ Requires-Dist: uvicorn>=0.48.0
23
+
24
+ # sqlalchemy-studio-backend
25
+
26
+ Backend utilities for inspecting and serving database tables via a FastAPI `Studio`.
27
+
28
+ This package exposes a small FastAPI app that can be embedded in your application or run
29
+ standalone to inspect a SQLAlchemy-accessible database.
30
+
31
+ Quick summary
32
+ - API endpoints (when using the packaged server):
33
+ - `GET /api/tables` — list tables and columns
34
+ - `GET /api/tables/{name}` — describe a single table
35
+ - `POST /api/{table_name}/query` — run the UI's advanced query payload
36
+
37
+ Install
38
+
39
+ ```
40
+ pip install sqlalchemy-studio-backend
41
+ ```
42
+
43
+ Or install from the repository for development/testing:
44
+
45
+ ```
46
+ pip install --upgrade git+https://github.com/yourusername/sqlalchemy-studio.git@main#subdirectory=sqlalchemy-studio-backend
47
+ ```
48
+
49
+ Quickstart
50
+
51
+ ```py
52
+ from sqlalchemy import create_engine
53
+ from sqlalchemy.orm import declarative_base
54
+ from db.main import Studio
55
+
56
+ Base = declarative_base()
57
+ engine = create_engine("sqlite:///test.db")
58
+
59
+ studio = Studio(engine, Base)
60
+ # Starts uvicorn and serves API under /api
61
+ studio.run(port=9000)
62
+ # API available at http://localhost:9000/api
63
+ ```
64
+
65
+ Serving a built frontend (optional)
66
+
67
+ If you build the Vite frontend, copy its `dist` output into the backend `static` folder
68
+ and the backend will serve the SPA from `/` while keeping the API under `/api`.
69
+
70
+ Example:
71
+
72
+ ```bash
73
+ # from repository root
74
+ npm --prefix sqlalchemy-studio-front install
75
+ npm --prefix sqlalchemy-studio-front run build
76
+ rm -rf sqlalchemy-studio-backend/static
77
+ mkdir -p sqlalchemy-studio-backend/static
78
+ cp -r sqlalchemy-studio-front/dist/* sqlalchemy-studio-backend/static/
79
+ ```
80
+
81
+ Configuration notes
82
+ - By default the package mounts static files at `/` and prefixes API routes with `/api` to
83
+ avoid SPA route collisions. Adjust `db/main.py` if you need a different layout.
84
+ - CORS: `Studio` registers a small set of development origins. When serving the SPA
85
+ from the same server you won't need CORS; keep/update `Studio._set_cors` for other setups.
86
+
87
+ Publishing
88
+
89
+ 1. Build the distribution locally:
90
+
91
+ ```bash
92
+ python -m pip install --upgrade build twine
93
+ python -m build
94
+ ```
95
+
96
+ 2. Upload to PyPI (CI should set `PYPI_API_TOKEN`):
97
+
98
+ ```bash
99
+ python -m twine upload dist/*
100
+ ```
101
+
102
+ License
103
+ MIT — update as appropriate.
@@ -0,0 +1,7 @@
1
+ studio/Studio.py,sha256=12CFpZmNoWL0P0-fh3DuSbpfmBbOv24wFY1Cy0pV9UE,7832
2
+ studio/__init__.py,sha256=cqNNSMf6N4vlXjnzC_Izcja2h7wETpUoQlO4sLtN4u4,33
3
+ studio/backend.py,sha256=VHIGezDrkvGnPWsfH-szug2-dkYBmJLcheTUwDcgO9k,1814
4
+ sqlalchemy_studio-0.1.0.dist-info/METADATA,sha256=veS9ZqdEm0hyN3f_4veL8ni6ZXRT2lZZvoRLoXUcxiw,3077
5
+ sqlalchemy_studio-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ sqlalchemy_studio-0.1.0.dist-info/top_level.txt,sha256=ZizOFiNZ7-jB61UbqOKLnpCYVrdXYlDLrGmFz16x1rs,7
7
+ sqlalchemy_studio-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ studio
studio/Studio.py ADDED
@@ -0,0 +1,232 @@
1
+ from sqlalchemy import and_, create_engine, func, inspect, Engine, or_, select, Table
2
+ from sqlalchemy.engine.interfaces import ReflectedColumn
3
+ from sqlalchemy.orm import DeclarativeBase
4
+ from fastapi import FastAPI, HTTPException, status
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+ import uvicorn
8
+
9
+
10
+ from studio .backend import create_tables_router
11
+
12
+ from typing import Self, TypedDict, Any, cast, TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from studio.backend import FilterRequest
16
+
17
+ engine = create_engine("sqlite:///test.db")
18
+
19
+
20
+ class TablesType(TypedDict):
21
+ table: str
22
+ columns: list[dict]
23
+
24
+
25
+ class ColumnDetails(TypedDict):
26
+ name: str
27
+ type: str
28
+ nullable: bool
29
+ default: Any
30
+ primary_key: bool
31
+
32
+
33
+ class SingleTableType(TypedDict):
34
+ table: str
35
+ primary_keys: list[str]
36
+ columns: list[ColumnDetails]
37
+
38
+
39
+ class GetRowsResponse(TypedDict):
40
+ rows: list[dict[str, Any]]
41
+ total_count: int
42
+
43
+
44
+ class Studio:
45
+ def __init__(self: Self, engine: Engine, base: DeclarativeBase) -> None:
46
+ self.engine = engine
47
+ self.inspector = inspect(self.engine)
48
+ self.app = FastAPI()
49
+ self._register_routes()
50
+ # Serve built frontend from the `static` directory at the project root.
51
+ # Keep this catch-all mount after API routes so `/api/*` resolves first.
52
+
53
+ self._set_cors()
54
+ self.base = base
55
+
56
+ def _set_cors(self):
57
+ origins = [
58
+ "http://localhost:5500",
59
+ "http://localhost:5173",
60
+ "http://localhost:8000",
61
+ "http://localhost:3000",
62
+ ]
63
+ self.app.add_middleware(
64
+ CORSMiddleware,
65
+ allow_origins=origins,
66
+ allow_credentials=True,
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+ def _register_routes(self):
72
+ self.app.include_router(create_tables_router(self))
73
+ self.app.mount("/", StaticFiles(directory="./studio/static", html=True), name="static")
74
+
75
+ def run(self, port: int = 8000):
76
+ print("-"*50)
77
+ print("-"*50)
78
+ print(f"link:http://localhost:{port}")
79
+ print("-"*50)
80
+ print("-"*50)
81
+ uvicorn.run(self.app, host="0.0.0.0", port=port)
82
+
83
+ def show_tables(self: Self) -> list[TablesType]:
84
+
85
+ tables_list: list[TablesType] = []
86
+ tables = self.inspector.get_table_names()
87
+ for table in tables:
88
+ tables_list.append(
89
+ {
90
+ "table": table,
91
+ "columns": [
92
+ {
93
+ **c,
94
+ "type": str(c["type"]),
95
+ }
96
+ for c in self.inspector.get_columns(table)
97
+ ],
98
+ }
99
+ )
100
+ return tables_list
101
+
102
+ def show_table(self: Self, table_name: str) -> SingleTableType:
103
+ """
104
+ Returns the structural data (columns, types, primary keys)
105
+ for a single specified table with strict typing for MyPy.
106
+ """
107
+ # 1. Check if the table exists
108
+ if not self.inspector.has_table(table_name):
109
+ raise HTTPException(
110
+ status_code=status.HTTP_404_NOT_FOUND,
111
+ detail=f"Table '{table_name}' not found.",
112
+ )
113
+
114
+ # 2. Reflect the table (keeps metadata clean/extended)
115
+ Table(
116
+ table_name,
117
+ self.base.metadata,
118
+ autoload_with=self.engine,
119
+ extend_existing=True,
120
+ )
121
+
122
+ # 3. Extract primary key details safely
123
+ pk_constraint = self.inspector.get_pk_constraint(table_name) or {}
124
+ # MyPy needs to know this evaluates cleanly to a list of strings
125
+ primary_keys = cast(list[str], pk_constraint.get("constrained_columns", []))
126
+ # 4. Extract and type-cast column information
127
+ columns_data: list[ColumnDetails] = []
128
+
129
+ # self.inspector.get_columns returns a list of dictionaries
130
+ raw_columns = self.inspector.get_columns(table_name)
131
+
132
+ for col in raw_columns:
133
+ columns_data.append(
134
+ {
135
+ "name": cast(str, col["name"]),
136
+ "type": str(col["type"]), # Stringify the type object for JSON
137
+ "nullable": bool(col["nullable"]),
138
+ "default": col.get("default"),
139
+ "primary_key": col.get("primary_key", 0) > 0,
140
+ }
141
+ )
142
+
143
+ # 5. Construct the final strictly typed response
144
+ response_data: SingleTableType = {
145
+ "table": table_name,
146
+ "primary_keys": primary_keys,
147
+ "columns": columns_data,
148
+ }
149
+
150
+ return response_data
151
+
152
+ def get_rows_advanced(
153
+ self: Self, table_name: str, payload: "FilterRequest"
154
+ ) -> GetRowsResponse:
155
+ """
156
+ Advanced filtering using logical operator trees matching custom UI filters.
157
+ """
158
+ if not self.inspector.has_table(table_name):
159
+ raise HTTPException(
160
+ status_code=404, detail=f"Table '{table_name}' not found."
161
+ )
162
+
163
+ table = Table(
164
+ table_name,
165
+ self.base.metadata,
166
+ autoload_with=self.engine,
167
+ extend_existing=True,
168
+ )
169
+
170
+ rows_query = select(table)
171
+ count_query = select(func.count()).select_from(table)
172
+
173
+ # Build dynamic expressions clauses
174
+ if payload.filters:
175
+ conditions = []
176
+
177
+ for cond in payload.filters:
178
+ if cond.column not in table.c:
179
+ continue # Guard against non-existent columns safely
180
+
181
+ col = table.c[cond.column]
182
+ val = cond.value
183
+
184
+ # Map string operators to SQLAlchemy expressions
185
+ if cond.operator == "equals":
186
+ expr = col == val
187
+ elif cond.operator == "not_equals":
188
+ expr = col != val
189
+ elif cond.operator == "contains":
190
+ expr = col.ilike(f"%{val}%")
191
+ elif cond.operator == "starts_with":
192
+ expr = col.ilike(f"{val}%")
193
+ elif cond.operator == "ends_with":
194
+ expr = col.ilike(f"%{val}")
195
+ elif cond.operator == "greater_than":
196
+ expr = col > val
197
+ elif cond.operator == "less_than":
198
+ expr = col < val
199
+ elif cond.operator == "greater_than_or_equals":
200
+ expr = col >= val
201
+ elif cond.operator == "less_than_or_equals":
202
+ expr = col <= val
203
+ else:
204
+ continue
205
+
206
+ # Combine them based on the link field ('and' / 'or')
207
+ if not conditions:
208
+ conditions.append(expr)
209
+ else:
210
+ if cond.link == "or":
211
+ # Merge last expression with current one via OR
212
+ prev_expr = conditions.pop()
213
+ conditions.append(or_(prev_expr, expr))
214
+ else:
215
+ # Default to AND chaining
216
+ prev_expr = conditions.pop()
217
+ conditions.append(and_(prev_expr, expr))
218
+
219
+ if conditions:
220
+ rows_query = rows_query.where(*conditions)
221
+ count_query = count_query.where(*conditions)
222
+
223
+ # Apply Pagination
224
+ rows_query = rows_query.limit(payload.limit).offset(payload.offset)
225
+
226
+ # Execute
227
+ with self.engine.connect() as connection:
228
+ total = int(connection.execute(count_query).scalar_one())
229
+ result = connection.execute(rows_query)
230
+ rows = [dict(row) for row in result.mappings()]
231
+
232
+ return {"rows": rows, "total_count": total}
studio/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from studio.Studio import Studio
studio/backend.py ADDED
@@ -0,0 +1,68 @@
1
+ from fastapi import APIRouter, Query, HTTPException,status
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from backend.Studio import Studio
7
+
8
+ from typing import Any, Literal, Self, TypedDict
9
+ from pydantic import BaseModel, Field
10
+
11
+ # Define acceptable operators matching your UI
12
+ OperatorType = Literal[
13
+ "equals",
14
+ "not_equals",
15
+ "contains",
16
+ "starts_with",
17
+ "ends_with",
18
+ "greater_than",
19
+ "less_than",
20
+ "greater_than_or_equals",
21
+ "less_than_or_equals",
22
+ ]
23
+ LinkType = Literal["and", "or"]
24
+
25
+
26
+ class FilterCondition(BaseModel):
27
+ column: str
28
+ operator: OperatorType
29
+ value: Any
30
+ link: LinkType = "and" # Connects this condition to the previous one
31
+
32
+
33
+ class FilterRequest(BaseModel):
34
+ limit: int = Field(default=10, ge=1)
35
+ offset: int = Field(default=0, ge=0)
36
+ filters: list[FilterCondition] = Field(default_factory=list)
37
+
38
+
39
+ def create_tables_router(studio: "Studio"):
40
+ router = APIRouter()
41
+
42
+ @router.get("/api/tables")
43
+ def get_tables():
44
+ return studio.show_tables()
45
+
46
+ @router.get("/api/tables/{name}")
47
+ def get_tables(name: str):
48
+ return studio.show_table(name)
49
+
50
+ @router.post("/api/{table_name}/query")
51
+ async def query_table_rows(
52
+ table_name: str,
53
+ payload: FilterRequest
54
+ ) -> Any:
55
+ """
56
+ Exposes advanced database table operations to handle compound UI filters.
57
+ """
58
+ try:
59
+ # Assuming `studio` is accessible in your router scope
60
+ return studio.get_rows_advanced(table_name, payload)
61
+ except HTTPException as e:
62
+ raise e
63
+ except Exception as e:
64
+ raise HTTPException(
65
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
66
+ detail=f"Query builder failed: {str(e)}"
67
+ )
68
+ return router