sirius-cli 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.
- sirius_cli/__init__.py +1 -0
- sirius_cli/cli.py +335 -0
- sirius_cli/generator.py +125 -0
- sirius_cli/parser.py +322 -0
- sirius_cli/templates/backend/Dockerfile.jinja2 +17 -0
- sirius_cli/templates/backend/alembic/env.py.jinja2 +70 -0
- sirius_cli/templates/backend/alembic/script.py.mako.jinja2 +26 -0
- sirius_cli/templates/backend/database.py.jinja2 +39 -0
- sirius_cli/templates/backend/main.py.jinja2 +178 -0
- sirius_cli/templates/backend/models.py.jinja2 +31 -0
- sirius_cli/templates/backend/requirements.txt.jinja2 +13 -0
- sirius_cli/templates/backend/schemas.py.jinja2 +80 -0
- sirius_cli/templates/docker-compose.yml.jinja2 +27 -0
- sirius_cli/templates/frontend/.env.jinja2 +3 -0
- sirius_cli/templates/frontend/Dockerfile.jinja2 +12 -0
- sirius_cli/templates/frontend/index.html.jinja2 +16 -0
- sirius_cli/templates/frontend/package.json.jinja2 +28 -0
- sirius_cli/templates/frontend/postcss.config.js.jinja2 +6 -0
- sirius_cli/templates/frontend/src/App.tsx.jinja2 +89 -0
- sirius_cli/templates/frontend/src/Dashboard.tsx.jinja2 +210 -0
- sirius_cli/templates/frontend/src/TableCrud.tsx.jinja2 +567 -0
- sirius_cli/templates/frontend/src/index.css.jinja2 +25 -0
- sirius_cli/templates/frontend/src/main.tsx.jinja2 +10 -0
- sirius_cli/templates/frontend/tailwind.config.js.jinja2 +21 -0
- sirius_cli/templates/frontend/tsconfig.json.jinja2 +25 -0
- sirius_cli/templates/frontend/vite.config.ts.jinja2 +10 -0
- sirius_cli-0.1.0.dist-info/METADATA +165 -0
- sirius_cli-0.1.0.dist-info/RECORD +32 -0
- sirius_cli-0.1.0.dist-info/WHEEL +5 -0
- sirius_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sirius_cli-0.1.0.dist-info/licenses/LICENSE +619 -0
- sirius_cli-0.1.0.dist-info/top_level.txt +1 -0
sirius_cli/parser.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sqlite3
|
|
4
|
+
import json
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
# Irregular plural/singular mappings for FK heuristic resolution
|
|
8
|
+
_IRREGULAR_PLURALS = {
|
|
9
|
+
"person": "people",
|
|
10
|
+
"man": "men",
|
|
11
|
+
"woman": "women",
|
|
12
|
+
"child": "children",
|
|
13
|
+
"tooth": "teeth",
|
|
14
|
+
"foot": "feet",
|
|
15
|
+
"mouse": "mice",
|
|
16
|
+
"goose": "geese",
|
|
17
|
+
"ox": "oxen",
|
|
18
|
+
"leaf": "leaves",
|
|
19
|
+
"life": "lives",
|
|
20
|
+
"knife": "knives",
|
|
21
|
+
"wife": "wives",
|
|
22
|
+
"half": "halves",
|
|
23
|
+
"loaf": "loaves",
|
|
24
|
+
"potato": "potatoes",
|
|
25
|
+
"tomato": "tomatoes",
|
|
26
|
+
"cactus": "cacti",
|
|
27
|
+
"focus": "foci",
|
|
28
|
+
"fungus": "fungi",
|
|
29
|
+
"nucleus": "nuclei",
|
|
30
|
+
"syllabus": "syllabi",
|
|
31
|
+
"analysis": "analyses",
|
|
32
|
+
"diagnosis": "diagnoses",
|
|
33
|
+
"oasis": "oases",
|
|
34
|
+
"thesis": "theses",
|
|
35
|
+
"crisis": "crises",
|
|
36
|
+
"phenomenon": "phenomena",
|
|
37
|
+
"criterion": "criteria",
|
|
38
|
+
"datum": "data",
|
|
39
|
+
}
|
|
40
|
+
_IRREGULAR_SINGULARS = {v: k for k, v in _IRREGULAR_PLURALS.items()}
|
|
41
|
+
|
|
42
|
+
def _pluralize(word: str) -> str:
|
|
43
|
+
"""Returns a best-guess plural of a singular English word."""
|
|
44
|
+
if word in _IRREGULAR_PLURALS:
|
|
45
|
+
return _IRREGULAR_PLURALS[word]
|
|
46
|
+
if word.endswith(("s", "sh", "ch", "x", "z")):
|
|
47
|
+
return word + "es"
|
|
48
|
+
if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou":
|
|
49
|
+
return word[:-1] + "ies"
|
|
50
|
+
if word.endswith(("f", "fe")):
|
|
51
|
+
return word.rstrip("e").rstrip("f") + "ves"
|
|
52
|
+
return word + "s"
|
|
53
|
+
|
|
54
|
+
def _singularize(word: str) -> str:
|
|
55
|
+
"""Returns a best-guess singular of a plural English word."""
|
|
56
|
+
if word in _IRREGULAR_SINGULARS:
|
|
57
|
+
return _IRREGULAR_SINGULARS[word]
|
|
58
|
+
if word.endswith("ies") and len(word) > 3:
|
|
59
|
+
return word[:-3] + "y"
|
|
60
|
+
if word.endswith("ves"):
|
|
61
|
+
# could be -f or -fe ending
|
|
62
|
+
return word[:-3] + "f"
|
|
63
|
+
if word.endswith("es") and word[:-2] in ("",) or word.endswith(("ses", "shes", "ches", "xes", "zes")):
|
|
64
|
+
return word[:-2]
|
|
65
|
+
if word.endswith("s") and not word.endswith("ss"):
|
|
66
|
+
return word[:-1]
|
|
67
|
+
return word
|
|
68
|
+
|
|
69
|
+
def sanitize_table_name(name: str) -> str:
|
|
70
|
+
"""Sanitizes file path/table name to a valid SQL identifier."""
|
|
71
|
+
base = os.path.splitext(os.path.basename(name))[0]
|
|
72
|
+
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', base).lower()
|
|
73
|
+
if sanitized and sanitized[0].isdigit():
|
|
74
|
+
sanitized = "_" + sanitized
|
|
75
|
+
return sanitized or "table"
|
|
76
|
+
|
|
77
|
+
def sanitize_column_name(name: str) -> str:
|
|
78
|
+
"""Sanitizes column name to a valid SQL/python attribute name."""
|
|
79
|
+
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', str(name)).lower()
|
|
80
|
+
if sanitized and sanitized[0].isdigit():
|
|
81
|
+
sanitized = "_" + sanitized
|
|
82
|
+
return sanitized or "column"
|
|
83
|
+
|
|
84
|
+
def map_pandas_type(dtype, sample_values) -> str:
|
|
85
|
+
"""Maps Pandas dtype to standard string types: Integer, Float, Boolean, DateTime, String."""
|
|
86
|
+
if pd.api.types.is_integer_dtype(dtype):
|
|
87
|
+
return "Integer"
|
|
88
|
+
elif pd.api.types.is_float_dtype(dtype):
|
|
89
|
+
return "Float"
|
|
90
|
+
elif pd.api.types.is_bool_dtype(dtype):
|
|
91
|
+
return "Boolean"
|
|
92
|
+
elif pd.api.types.is_datetime64_any_dtype(dtype):
|
|
93
|
+
return "DateTime"
|
|
94
|
+
else:
|
|
95
|
+
# Check string samples to see if they look like ISO dates or timestamps
|
|
96
|
+
for val in sample_values:
|
|
97
|
+
if isinstance(val, str):
|
|
98
|
+
try:
|
|
99
|
+
pd.to_datetime(val)
|
|
100
|
+
if len(val) >= 8 and any(char in val for char in ['-', '/', ':']):
|
|
101
|
+
return "DateTime"
|
|
102
|
+
except (ValueError, TypeError):
|
|
103
|
+
pass
|
|
104
|
+
return "String"
|
|
105
|
+
|
|
106
|
+
def parse_config_file(config_path: str):
|
|
107
|
+
"""Loads entities and schemas directly from a JSON configuration file."""
|
|
108
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
109
|
+
config = json.load(f)
|
|
110
|
+
|
|
111
|
+
project_name = config.get("project_name", "generated_project")
|
|
112
|
+
theme = config.get("theme", "blue")
|
|
113
|
+
entities = config.get("entities", {})
|
|
114
|
+
|
|
115
|
+
schemas = {}
|
|
116
|
+
for table_name, table_info in entities.items():
|
|
117
|
+
san_table = sanitize_table_name(table_name)
|
|
118
|
+
columns = []
|
|
119
|
+
has_id = False
|
|
120
|
+
|
|
121
|
+
for col in table_info.get("columns", []):
|
|
122
|
+
name = sanitize_column_name(col.get("name"))
|
|
123
|
+
c_type = col.get("type", "String")
|
|
124
|
+
is_pk = col.get("is_pk", False)
|
|
125
|
+
fk = col.get("foreign_key")
|
|
126
|
+
|
|
127
|
+
col_dict = {"name": name, "type": c_type, "is_pk": is_pk}
|
|
128
|
+
if fk:
|
|
129
|
+
col_dict["foreign_key"] = fk
|
|
130
|
+
|
|
131
|
+
if name == "id":
|
|
132
|
+
has_id = True
|
|
133
|
+
col_dict["is_pk"] = True
|
|
134
|
+
|
|
135
|
+
columns.append(col_dict)
|
|
136
|
+
|
|
137
|
+
if not has_id:
|
|
138
|
+
# Enforce an autoincrementing primary key
|
|
139
|
+
for c in columns:
|
|
140
|
+
if c["is_pk"]:
|
|
141
|
+
c["is_pk"] = False
|
|
142
|
+
columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
|
|
143
|
+
|
|
144
|
+
schemas[san_table] = columns
|
|
145
|
+
|
|
146
|
+
return schemas, project_name, theme
|
|
147
|
+
|
|
148
|
+
def parse_csv_files(csv_paths: list) -> dict:
|
|
149
|
+
"""Parses list of CSV paths, extracts schema types and infers relationships."""
|
|
150
|
+
schemas = {}
|
|
151
|
+
for path in csv_paths:
|
|
152
|
+
if not os.path.exists(path):
|
|
153
|
+
raise FileNotFoundError(f"CSV file not found: {path}")
|
|
154
|
+
|
|
155
|
+
table_name = sanitize_table_name(path)
|
|
156
|
+
df = pd.read_csv(path, nrows=100)
|
|
157
|
+
|
|
158
|
+
columns = []
|
|
159
|
+
has_id = False
|
|
160
|
+
|
|
161
|
+
for col in df.columns:
|
|
162
|
+
san_col = sanitize_column_name(col)
|
|
163
|
+
col_type = map_pandas_type(df[col].dtype, df[col].dropna().head(5).tolist())
|
|
164
|
+
|
|
165
|
+
if san_col == "id":
|
|
166
|
+
has_id = True
|
|
167
|
+
columns.append({"name": "id", "type": "Integer", "is_pk": True})
|
|
168
|
+
else:
|
|
169
|
+
columns.append({"name": san_col, "type": col_type, "is_pk": False})
|
|
170
|
+
|
|
171
|
+
if not has_id:
|
|
172
|
+
columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
|
|
173
|
+
|
|
174
|
+
schemas[table_name] = columns
|
|
175
|
+
|
|
176
|
+
# Heuristic-based Relationship inference (shared by CSV and Excel parsers)
|
|
177
|
+
table_names = list(schemas.keys())
|
|
178
|
+
for table_name, columns in schemas.items():
|
|
179
|
+
for col in columns:
|
|
180
|
+
if col["name"].endswith("_id") and col["name"] != "id":
|
|
181
|
+
prefix = col["name"][:-3] # strip '_id'
|
|
182
|
+
matched_table = None
|
|
183
|
+
for t in table_names:
|
|
184
|
+
# Try exact match, then common plural/singular variants
|
|
185
|
+
candidates = {
|
|
186
|
+
prefix,
|
|
187
|
+
_pluralize(prefix),
|
|
188
|
+
_singularize(prefix),
|
|
189
|
+
prefix + "s",
|
|
190
|
+
prefix[:-1] if prefix.endswith("s") else prefix,
|
|
191
|
+
}
|
|
192
|
+
if t in candidates:
|
|
193
|
+
matched_table = t
|
|
194
|
+
break
|
|
195
|
+
if matched_table:
|
|
196
|
+
col["foreign_key"] = f"{matched_table}.id"
|
|
197
|
+
|
|
198
|
+
return schemas
|
|
199
|
+
|
|
200
|
+
def parse_excel_files(excel_paths: list) -> dict:
|
|
201
|
+
"""Parses list of Excel (.xlsx/.xls) paths, extracts schema types and infers relationships."""
|
|
202
|
+
schemas = {}
|
|
203
|
+
for path in excel_paths:
|
|
204
|
+
if not os.path.exists(path):
|
|
205
|
+
raise FileNotFoundError(f"Excel file not found: {path}")
|
|
206
|
+
|
|
207
|
+
table_name = sanitize_table_name(path)
|
|
208
|
+
df = pd.read_excel(path, nrows=100)
|
|
209
|
+
|
|
210
|
+
columns = []
|
|
211
|
+
has_id = False
|
|
212
|
+
|
|
213
|
+
for col in df.columns:
|
|
214
|
+
san_col = sanitize_column_name(str(col))
|
|
215
|
+
col_type = map_pandas_type(df[col].dtype, df[col].dropna().head(5).tolist())
|
|
216
|
+
|
|
217
|
+
if san_col == "id":
|
|
218
|
+
has_id = True
|
|
219
|
+
columns.append({"name": "id", "type": "Integer", "is_pk": True})
|
|
220
|
+
else:
|
|
221
|
+
columns.append({"name": san_col, "type": col_type, "is_pk": False})
|
|
222
|
+
|
|
223
|
+
if not has_id:
|
|
224
|
+
columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
|
|
225
|
+
|
|
226
|
+
schemas[table_name] = columns
|
|
227
|
+
|
|
228
|
+
# Run same FK heuristic as CSV parser
|
|
229
|
+
table_names = list(schemas.keys())
|
|
230
|
+
for table_name, columns in schemas.items():
|
|
231
|
+
for col in columns:
|
|
232
|
+
if col["name"].endswith("_id") and col["name"] != "id":
|
|
233
|
+
prefix = col["name"][:-3]
|
|
234
|
+
matched_table = None
|
|
235
|
+
for t in table_names:
|
|
236
|
+
candidates = {
|
|
237
|
+
prefix,
|
|
238
|
+
_pluralize(prefix),
|
|
239
|
+
_singularize(prefix),
|
|
240
|
+
prefix + "s",
|
|
241
|
+
prefix[:-1] if prefix.endswith("s") else prefix,
|
|
242
|
+
}
|
|
243
|
+
if t in candidates:
|
|
244
|
+
matched_table = t
|
|
245
|
+
break
|
|
246
|
+
if matched_table:
|
|
247
|
+
col["foreign_key"] = f"{matched_table}.id"
|
|
248
|
+
|
|
249
|
+
return schemas
|
|
250
|
+
|
|
251
|
+
def map_sqlite_type(sqlite_type: str) -> str:
|
|
252
|
+
"""Maps SQLite column type to standard schema types."""
|
|
253
|
+
t = sqlite_type.upper()
|
|
254
|
+
if any(x in t for x in ["INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"]):
|
|
255
|
+
return "Integer"
|
|
256
|
+
elif any(x in t for x in ["REAL", "DOUBLE", "FLOAT"]):
|
|
257
|
+
return "Float"
|
|
258
|
+
elif any(x in t for x in ["BOOL", "BOOLEAN"]):
|
|
259
|
+
return "Boolean"
|
|
260
|
+
elif any(x in t for x in ["DATE", "DATETIME", "TIMESTAMP"]):
|
|
261
|
+
return "DateTime"
|
|
262
|
+
else:
|
|
263
|
+
return "String"
|
|
264
|
+
|
|
265
|
+
def parse_sqlite_db(db_path: str) -> dict:
|
|
266
|
+
"""Parses SQLite database extracting user tables and foreign key constraints."""
|
|
267
|
+
if not os.path.exists(db_path):
|
|
268
|
+
raise FileNotFoundError(f"SQLite database file not found: {db_path}")
|
|
269
|
+
|
|
270
|
+
schemas = {}
|
|
271
|
+
conn = sqlite3.connect(db_path)
|
|
272
|
+
cursor = conn.cursor()
|
|
273
|
+
|
|
274
|
+
# Query user tables
|
|
275
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';")
|
|
276
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
277
|
+
|
|
278
|
+
for table in tables:
|
|
279
|
+
san_table = sanitize_table_name(table)
|
|
280
|
+
|
|
281
|
+
# Query SQLite foreign key relationships for the table
|
|
282
|
+
# row structure: (id, seq, table, from, to, on_update, on_delete, match)
|
|
283
|
+
cursor.execute(f"PRAGMA foreign_key_list({table});")
|
|
284
|
+
fks = {}
|
|
285
|
+
for row in cursor.fetchall():
|
|
286
|
+
local_col = sanitize_column_name(row[3])
|
|
287
|
+
ref_table = sanitize_table_name(row[2])
|
|
288
|
+
ref_col = sanitize_column_name(row[4])
|
|
289
|
+
fks[local_col] = f"{ref_table}.{ref_col}"
|
|
290
|
+
|
|
291
|
+
cursor.execute(f"PRAGMA table_info({table});")
|
|
292
|
+
columns_info = cursor.fetchall()
|
|
293
|
+
|
|
294
|
+
columns = []
|
|
295
|
+
has_id = False
|
|
296
|
+
|
|
297
|
+
for info in columns_info:
|
|
298
|
+
col_name = sanitize_column_name(info[1])
|
|
299
|
+
col_type = map_sqlite_type(info[2])
|
|
300
|
+
is_pk = bool(info[5])
|
|
301
|
+
|
|
302
|
+
col_dict = {"name": col_name, "type": col_type, "is_pk": is_pk}
|
|
303
|
+
if col_name in fks:
|
|
304
|
+
col_dict["foreign_key"] = fks[col_name]
|
|
305
|
+
|
|
306
|
+
if col_name == "id":
|
|
307
|
+
has_id = True
|
|
308
|
+
col_dict["is_pk"] = True
|
|
309
|
+
|
|
310
|
+
columns.append(col_dict)
|
|
311
|
+
|
|
312
|
+
if not has_id:
|
|
313
|
+
# Enforce PK id
|
|
314
|
+
for c in columns:
|
|
315
|
+
if c["is_pk"]:
|
|
316
|
+
c["is_pk"] = False
|
|
317
|
+
columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
|
|
318
|
+
|
|
319
|
+
schemas[san_table] = columns
|
|
320
|
+
|
|
321
|
+
conn.close()
|
|
322
|
+
return schemas
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
FROM python:3.10-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
6
|
+
build-essential \
|
|
7
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
8
|
+
|
|
9
|
+
COPY requirements.txt .
|
|
10
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
11
|
+
|
|
12
|
+
COPY . .
|
|
13
|
+
|
|
14
|
+
EXPOSE {{ port }}
|
|
15
|
+
|
|
16
|
+
# We assume uvicorn is installed and backend is a python package
|
|
17
|
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "{{ port }}"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
# Add parent of the backend directory to sys.path so backend is importable
|
|
4
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
|
5
|
+
|
|
6
|
+
from logging.config import fileConfig
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import engine_from_config
|
|
9
|
+
from sqlalchemy import pool
|
|
10
|
+
|
|
11
|
+
from alembic import context
|
|
12
|
+
|
|
13
|
+
# this is the Alembic Config object, which provides
|
|
14
|
+
# access to the values within the .ini file in use.
|
|
15
|
+
config = context.config
|
|
16
|
+
|
|
17
|
+
# Interpret the config file for Python logging.
|
|
18
|
+
# This line sets up loggers basically.
|
|
19
|
+
if config.config_file_name is not None:
|
|
20
|
+
fileConfig(config.config_file_name)
|
|
21
|
+
|
|
22
|
+
# Import engine and Base from backend package
|
|
23
|
+
from backend.database import Base, engine
|
|
24
|
+
import backend.models # ensures models are loaded for metadata extraction
|
|
25
|
+
target_metadata = Base.metadata
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_migrations_offline() -> None:
|
|
29
|
+
"""Run migrations in 'offline' mode.
|
|
30
|
+
|
|
31
|
+
This configures the context with just a URL
|
|
32
|
+
and not an Engine, though an Engine is acceptable
|
|
33
|
+
here as well. By skipping the Engine creation
|
|
34
|
+
we don't even need a DBAPI to be available.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
url = str(engine.url)
|
|
38
|
+
context.configure(
|
|
39
|
+
url=url,
|
|
40
|
+
target_metadata=target_metadata,
|
|
41
|
+
literal_binds=True,
|
|
42
|
+
dialect_opts={"paramstyle": "named"},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
with context.begin_transaction():
|
|
46
|
+
context.run_migrations()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_migrations_online() -> None:
|
|
50
|
+
"""Run migrations in 'online' mode.
|
|
51
|
+
|
|
52
|
+
In this scenario we need to create an Engine
|
|
53
|
+
and associate a connection with the context.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
connectable = engine
|
|
57
|
+
|
|
58
|
+
with connectable.connect() as connection:
|
|
59
|
+
context.configure(
|
|
60
|
+
connection=connection, target_metadata=target_metadata
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with context.begin_transaction():
|
|
64
|
+
context.run_migrations()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if context.is_offline_mode():
|
|
68
|
+
run_migrations_offline()
|
|
69
|
+
else:
|
|
70
|
+
run_migrations_online()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{% raw %}
|
|
2
|
+
"""${message}
|
|
3
|
+
|
|
4
|
+
Revision ID: ${up_revision}
|
|
5
|
+
Revises: ${down_revision}
|
|
6
|
+
Create Date: ${create_date}
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
from alembic import op
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
${imports if imports else ""}
|
|
12
|
+
|
|
13
|
+
# revision identifiers, used by Alembic.
|
|
14
|
+
revision = ${repr(up_revision)}
|
|
15
|
+
down_revision = ${repr(down_revision)}
|
|
16
|
+
branch_labels = ${repr(branch_labels)}
|
|
17
|
+
depends_on = ${repr(depends_on)}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upgrade() -> None:
|
|
21
|
+
${upgrades if upgrades else "pass"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def downgrade() -> None:
|
|
25
|
+
${downgrades if downgrades else "pass"}
|
|
26
|
+
{% endraw %}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from sqlalchemy import create_engine
|
|
3
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
4
|
+
from sqlalchemy.orm import sessionmaker
|
|
5
|
+
|
|
6
|
+
{% if db_type == 'pg' %}
|
|
7
|
+
# PostgreSQL connection — set DATABASE_URL environment variable for custom credentials
|
|
8
|
+
DATABASE_URL = os.environ.get(
|
|
9
|
+
"DATABASE_URL",
|
|
10
|
+
"postgresql://postgres:postgres@localhost:5432/{{ project_name | default('app', true) }}"
|
|
11
|
+
)
|
|
12
|
+
engine = create_engine(DATABASE_URL)
|
|
13
|
+
{% elif db_type == 'mysql' %}
|
|
14
|
+
# MySQL connection — set DATABASE_URL environment variable for custom credentials
|
|
15
|
+
DATABASE_URL = os.environ.get(
|
|
16
|
+
"DATABASE_URL",
|
|
17
|
+
"mysql+pymysql://root:root@localhost:3306/{{ project_name | default('app', true) }}"
|
|
18
|
+
)
|
|
19
|
+
engine = create_engine(DATABASE_URL)
|
|
20
|
+
{% else %}
|
|
21
|
+
# SQLite (default) — file lives alongside the backend package
|
|
22
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
db_path = os.path.join(BASE_DIR, "app.db")
|
|
24
|
+
SQLALCHEMY_DATABASE_URL = f"sqlite:///{db_path}"
|
|
25
|
+
engine = create_engine(
|
|
26
|
+
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
|
27
|
+
)
|
|
28
|
+
{% endif %}
|
|
29
|
+
|
|
30
|
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
31
|
+
|
|
32
|
+
Base = declarative_base()
|
|
33
|
+
|
|
34
|
+
def get_db():
|
|
35
|
+
db = SessionLocal()
|
|
36
|
+
try:
|
|
37
|
+
yield db
|
|
38
|
+
finally:
|
|
39
|
+
db.close()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from fastapi import FastAPI, Depends, HTTPException, Query, status
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
from fastapi.responses import StreamingResponse
|
|
6
|
+
from sqlalchemy import or_, cast, String as SAString, desc as sa_desc, asc as sa_asc
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from .database import engine, get_db
|
|
11
|
+
from . import models, schemas
|
|
12
|
+
|
|
13
|
+
# Automatically create tables if they do not exist
|
|
14
|
+
models.Base.metadata.create_all(bind=engine)
|
|
15
|
+
|
|
16
|
+
app = FastAPI(title="Proto-CLI Generated Backend API", version="0.1.0")
|
|
17
|
+
|
|
18
|
+
app.add_middleware(
|
|
19
|
+
CORSMiddleware,
|
|
20
|
+
allow_origins=["*"],
|
|
21
|
+
allow_credentials=True,
|
|
22
|
+
allow_methods=["*"],
|
|
23
|
+
allow_headers=["*"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@app.get("/")
|
|
27
|
+
def read_root():
|
|
28
|
+
return {"message": "Welcome to the generated Proto-CLI API!"}
|
|
29
|
+
|
|
30
|
+
@app.get("/api/metadata")
|
|
31
|
+
def get_metadata(db: Session = Depends(get_db)):
|
|
32
|
+
meta = []
|
|
33
|
+
{% for table_name, columns in schemas.items() %}
|
|
34
|
+
{% set model_name = table_name.replace('_', ' ').title().replace(' ', '') + "Model" %}
|
|
35
|
+
meta.append({
|
|
36
|
+
"name": "{{ table_name }}",
|
|
37
|
+
"count": db.query(models.{{ model_name }}).count(),
|
|
38
|
+
"columns": json.loads("""{{ columns | tojson }}""")
|
|
39
|
+
})
|
|
40
|
+
{% endfor %}
|
|
41
|
+
return meta
|
|
42
|
+
|
|
43
|
+
{% for table_name, columns in schemas.items() %}
|
|
44
|
+
{% set model_name = table_name.replace('_', ' ').title().replace(' ', '') + "Model" %}
|
|
45
|
+
{% set schema_response = table_name.replace('_', ' ').title().replace(' ', '') + "Response" %}
|
|
46
|
+
{% set schema_create = table_name.replace('_', ' ').title().replace(' ', '') + "Create" %}
|
|
47
|
+
{% set schema_update = table_name.replace('_', ' ').title().replace(' ', '') + "Update" %}
|
|
48
|
+
|
|
49
|
+
# --- Endpoints for {{ table_name }} ---
|
|
50
|
+
|
|
51
|
+
@app.get("/api/{{ table_name }}", response_model=List[schemas.{{ schema_response }}])
|
|
52
|
+
def list_{{ table_name }}(
|
|
53
|
+
limit: int = 100,
|
|
54
|
+
offset: int = 0,
|
|
55
|
+
search: Optional[str] = Query(None, description="Search across all text columns"),
|
|
56
|
+
order_by: Optional[str] = Query(None, description="Column name to sort by"),
|
|
57
|
+
dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
|
|
58
|
+
db: Session = Depends(get_db)
|
|
59
|
+
):
|
|
60
|
+
query = db.query(models.{{ model_name }})
|
|
61
|
+
|
|
62
|
+
# Server-side search: filter across all string-castable columns
|
|
63
|
+
if search:
|
|
64
|
+
search_term = f"%{search}%"
|
|
65
|
+
search_filters = []
|
|
66
|
+
for col in models.{{ model_name }}.__table__.columns:
|
|
67
|
+
search_filters.append(cast(col, SAString).ilike(search_term))
|
|
68
|
+
if search_filters:
|
|
69
|
+
query = query.filter(or_(*search_filters))
|
|
70
|
+
|
|
71
|
+
# Column sorting
|
|
72
|
+
if order_by and hasattr(models.{{ model_name }}, order_by):
|
|
73
|
+
col_attr = getattr(models.{{ model_name }}, order_by)
|
|
74
|
+
query = query.order_by(sa_desc(col_attr) if dir == "desc" else sa_asc(col_attr))
|
|
75
|
+
|
|
76
|
+
records = query.offset(offset).limit(limit).all()
|
|
77
|
+
return records
|
|
78
|
+
|
|
79
|
+
@app.get("/api/{{ table_name }}/export")
|
|
80
|
+
def export_{{ table_name }}(
|
|
81
|
+
fmt: Optional[str] = Query("csv", description="Export format: csv or xlsx"),
|
|
82
|
+
db: Session = Depends(get_db)
|
|
83
|
+
):
|
|
84
|
+
records = db.query(models.{{ model_name }}).all()
|
|
85
|
+
col_names = [c.name for c in models.{{ model_name }}.__table__.columns]
|
|
86
|
+
|
|
87
|
+
if fmt == "xlsx":
|
|
88
|
+
try:
|
|
89
|
+
from openpyxl import Workbook
|
|
90
|
+
except ImportError:
|
|
91
|
+
raise HTTPException(status_code=500, detail="openpyxl is not installed on the server")
|
|
92
|
+
|
|
93
|
+
wb = Workbook()
|
|
94
|
+
ws = wb.active
|
|
95
|
+
ws.title = "{{ table_name }}"
|
|
96
|
+
ws.append(col_names)
|
|
97
|
+
for record in records:
|
|
98
|
+
row = []
|
|
99
|
+
for col in col_names:
|
|
100
|
+
val = getattr(record, col)
|
|
101
|
+
row.append(val if val is not None else "")
|
|
102
|
+
ws.append(row)
|
|
103
|
+
|
|
104
|
+
buf = io.BytesIO()
|
|
105
|
+
wb.save(buf)
|
|
106
|
+
buf.seek(0)
|
|
107
|
+
|
|
108
|
+
return StreamingResponse(
|
|
109
|
+
buf,
|
|
110
|
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
111
|
+
headers={"Content-Disposition": "attachment; filename={{ table_name }}.xlsx"}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Default: CSV export
|
|
115
|
+
def generate():
|
|
116
|
+
yield ",".join(col_names) + "\n"
|
|
117
|
+
for record in records:
|
|
118
|
+
row_values = []
|
|
119
|
+
for col in col_names:
|
|
120
|
+
val = getattr(record, col)
|
|
121
|
+
if val is None:
|
|
122
|
+
row_values.append("")
|
|
123
|
+
elif isinstance(val, bool):
|
|
124
|
+
row_values.append(str(val))
|
|
125
|
+
else:
|
|
126
|
+
val_str = str(val).replace('"', '""')
|
|
127
|
+
if ',' in val_str or '\n' in val_str or '"' in val_str:
|
|
128
|
+
row_values.append(f'"{val_str}"')
|
|
129
|
+
else:
|
|
130
|
+
row_values.append(val_str)
|
|
131
|
+
yield ",".join(row_values) + "\n"
|
|
132
|
+
|
|
133
|
+
return StreamingResponse(
|
|
134
|
+
generate(),
|
|
135
|
+
media_type="text/csv",
|
|
136
|
+
headers={"Content-Disposition": "attachment; filename={{ table_name }}.csv"}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@app.get("/api/{{ table_name }}/{id}", response_model=schemas.{{ schema_response }})
|
|
140
|
+
def get_{{ table_name }}(id: int, db: Session = Depends(get_db)):
|
|
141
|
+
record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
|
|
142
|
+
if not record:
|
|
143
|
+
raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
|
|
144
|
+
return record
|
|
145
|
+
|
|
146
|
+
@app.post("/api/{{ table_name }}", response_model=schemas.{{ schema_response }}, status_code=status.HTTP_201_CREATED)
|
|
147
|
+
def create_{{ table_name }}(payload: schemas.{{ schema_create }}, db: Session = Depends(get_db)):
|
|
148
|
+
db_record = models.{{ model_name }}(**payload.model_dump())
|
|
149
|
+
db.add(db_record)
|
|
150
|
+
db.commit()
|
|
151
|
+
db.refresh(db_record)
|
|
152
|
+
return db_record
|
|
153
|
+
|
|
154
|
+
@app.put("/api/{{ table_name }}/{id}", response_model=schemas.{{ schema_response }})
|
|
155
|
+
def update_{{ table_name }}(id: int, payload: schemas.{{ schema_update }}, db: Session = Depends(get_db)):
|
|
156
|
+
db_record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
|
|
157
|
+
if not db_record:
|
|
158
|
+
raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
|
|
159
|
+
|
|
160
|
+
update_data = payload.model_dump(exclude_unset=True)
|
|
161
|
+
for key, value in update_data.items():
|
|
162
|
+
setattr(db_record, key, value)
|
|
163
|
+
|
|
164
|
+
db.commit()
|
|
165
|
+
db.refresh(db_record)
|
|
166
|
+
return db_record
|
|
167
|
+
|
|
168
|
+
@app.delete("/api/{{ table_name }}/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
169
|
+
def delete_{{ table_name }}(id: int, db: Session = Depends(get_db)):
|
|
170
|
+
db_record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
|
|
171
|
+
if not db_record:
|
|
172
|
+
raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
|
|
173
|
+
|
|
174
|
+
db.delete(db_record)
|
|
175
|
+
db.commit()
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
{% endfor %}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from sqlalchemy import Column, Integer, Float, String, Boolean, DateTime, ForeignKey
|
|
2
|
+
from sqlalchemy.orm import relationship
|
|
3
|
+
from .database import Base
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
{% for table_name, columns in schemas.items() %}
|
|
7
|
+
{% set class_name = table_name.replace('_', ' ').title().replace(' ', '') + "Model" %}
|
|
8
|
+
class {{ class_name }}(Base):
|
|
9
|
+
__tablename__ = "{{ table_name }}"
|
|
10
|
+
|
|
11
|
+
{% for col in columns %}
|
|
12
|
+
{% if col.is_pk %}
|
|
13
|
+
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
|
14
|
+
{% elif col.foreign_key %}
|
|
15
|
+
{{ col.name }} = Column({{ col.type }}, ForeignKey('{{ col.foreign_key }}'), nullable=True)
|
|
16
|
+
{% else %}
|
|
17
|
+
{{ col.name }} = Column({{ col.type }}, nullable=True)
|
|
18
|
+
{% endif %}
|
|
19
|
+
{% endfor %}
|
|
20
|
+
|
|
21
|
+
# Relationships
|
|
22
|
+
{% for col in columns %}
|
|
23
|
+
{% if col.foreign_key %}
|
|
24
|
+
{% set ref_table = col.foreign_key.split('.')[0] %}
|
|
25
|
+
{% set ref_class = ref_table.replace('_', ' ').title().replace(' ', '') + "Model" %}
|
|
26
|
+
{% set prop_name = col.name[:-3] if col.name.endswith('_id') else ref_table %}
|
|
27
|
+
{{ prop_name }} = relationship("{{ ref_class }}")
|
|
28
|
+
{% endif %}
|
|
29
|
+
{% endfor %}
|
|
30
|
+
|
|
31
|
+
{% endfor %}
|