shahed125238 commited on
Commit
16793ec
·
verified ·
1 Parent(s): 1127efd

Upload 3 files

Browse files
Files changed (3) hide show
  1. .env +4 -0
  2. app.py +587 -0
  3. requirements.txt +7 -0
.env ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ GOOGLE_API_KEY=AQ.Ab8RN6LHRAnA-2C98mESLiiFdyruPB60QVrfekf-vYL1HribMQ
2
+ SERVER=DESKTOP-322IVR3\SQLEXPRESS
3
+ DATABASE=test
4
+ DRIVER=ODBC Driver 17 for SQL Server
app.py ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import logging
4
+ from urllib.parse import quote_plus
5
+
6
+ import pandas as pd
7
+ import streamlit as st
8
+ import plotly.express as px
9
+
10
+ from dotenv import load_dotenv
11
+ from sqlalchemy import create_engine, text
12
+ from google import genai
13
+
14
+ # -----------------------------
15
+ # Page Config (must be the first Streamlit call)
16
+ # -----------------------------
17
+
18
+ st.set_page_config(page_title="AI SQL Assistant", layout="wide")
19
+
20
+ logging.basicConfig(level=logging.INFO)
21
+ logger = logging.getLogger("ai_sql_assistant")
22
+
23
+ # -----------------------------
24
+ # Config
25
+ # -----------------------------
26
+
27
+ # NOTE: confirm the exact Gemini model id you have access to
28
+ # (this is intentionally kept in one place so it's easy to change).
29
+ GEMINI_MODEL = "models/gemini-3.5-flash"
30
+
31
+ MAX_ROWS = 1000 # hard cap injected into generated SELECTs to avoid huge result sets
32
+
33
+ # Statement-level and keyword-level SQL guardrails.
34
+ # App-layer filtering is defense-in-depth only — whichever DB login is used
35
+ # below should ALSO be a read-only account (db_datareader), never a login
36
+ # with write/DDL permissions.
37
+ FORBIDDEN_KEYWORDS = [
38
+ "INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE",
39
+ "MERGE", "EXEC", "EXECUTE", "CREATE", "GRANT", "REVOKE",
40
+ "sp_", "xp_",
41
+ ]
42
+
43
+ # -----------------------------
44
+ # Load Environment Variables (used only as prefill defaults now —
45
+ # the sidebar lets you override every one of these and switch databases
46
+ # without restarting the app)
47
+ # -----------------------------
48
+
49
+ load_dotenv()
50
+
51
+ api_key = os.getenv("GOOGLE_API_KEY")
52
+
53
+ default_server = os.getenv("SERVER", "")
54
+ default_database = os.getenv("DATABASE", "")
55
+ default_driver = os.getenv("DRIVER", "ODBC Driver 17 for SQL Server")
56
+
57
+ if not api_key:
58
+ st.error("Missing required environment variable: GOOGLE_API_KEY")
59
+ st.stop()
60
+
61
+ # -----------------------------
62
+ # Gemini Client
63
+ # -----------------------------
64
+
65
+ client = genai.Client(api_key=api_key)
66
+
67
+ # -----------------------------
68
+ # Connection helpers
69
+ # -----------------------------
70
+
71
+
72
+ def build_connection_string(srv, db, drv, auth_type, username=None, password=None):
73
+ drv_enc = drv.replace(" ", "+")
74
+
75
+ if auth_type == "SQL Login":
76
+ user_enc = quote_plus(username or "")
77
+ pwd_enc = quote_plus(password or "")
78
+ return (
79
+ f"mssql+pyodbc://{user_enc}:{pwd_enc}@{srv}/{db}"
80
+ f"?driver={drv_enc}"
81
+ )
82
+
83
+ # Windows / trusted connection
84
+ return (
85
+ f"mssql+pyodbc://@{srv}/{db}"
86
+ f"?driver={drv_enc}"
87
+ "&trusted_connection=yes"
88
+ )
89
+
90
+
91
+ def create_and_test_engine(connection_string):
92
+ """Build an engine and immediately test connectivity.
93
+ Raises on failure so the caller can show a clear error."""
94
+ eng = create_engine(connection_string, pool_pre_ping=True)
95
+ with eng.connect():
96
+ pass
97
+ return eng
98
+
99
+
100
+ def load_schema(engine):
101
+ sql = """
102
+ SELECT
103
+ TABLE_NAME,
104
+ COLUMN_NAME,
105
+ DATA_TYPE
106
+ FROM INFORMATION_SCHEMA.COLUMNS
107
+ ORDER BY TABLE_NAME, ORDINAL_POSITION
108
+ """
109
+
110
+ df = pd.read_sql(sql, engine)
111
+
112
+ schema = ""
113
+
114
+ for table in df["TABLE_NAME"].unique():
115
+
116
+ schema += f"\nTable : {table}\n"
117
+
118
+ cols = df[df["TABLE_NAME"] == table]
119
+
120
+ for _, row in cols.iterrows():
121
+ schema += f"- {row['COLUMN_NAME']} ({row['DATA_TYPE']})\n"
122
+
123
+ return schema
124
+
125
+
126
+ def load_tables(engine):
127
+ return pd.read_sql(
128
+ """
129
+ SELECT TABLE_NAME
130
+ FROM INFORMATION_SCHEMA.TABLES
131
+ WHERE TABLE_TYPE='BASE TABLE'
132
+ """,
133
+ engine
134
+ )
135
+
136
+
137
+ def is_query_safe(sql: str):
138
+ """Defense-in-depth check on top of the read-only DB login.
139
+
140
+ Rejects multi-statement input and any forbidden keyword, matched on
141
+ word boundaries so it can't be trivially bypassed by e.g. 'SELECTED'.
142
+
143
+ Allows queries that start with SELECT *or* WITH (CTE), since the model
144
+ frequently generates `WITH ... AS (...) SELECT ...` for window-function
145
+ questions. Both are still read-only as long as no forbidden keyword
146
+ appears anywhere in the statement.
147
+
148
+ Returns (is_safe: bool, reason: str).
149
+ """
150
+ stripped = sql.strip()
151
+
152
+ if ";" in stripped.rstrip(";"):
153
+ return False, "Multiple SQL statements are not allowed."
154
+
155
+ stripped_upper = stripped.upper()
156
+ if not (stripped_upper.startswith("SELECT") or stripped_upper.startswith("WITH")):
157
+ return False, "Only SELECT (or WITH ... SELECT) statements are allowed."
158
+
159
+ upper_sql = stripped_upper
160
+ for kw in FORBIDDEN_KEYWORDS:
161
+ pattern = r"\b" + re.escape(kw.upper()) + r"\b"
162
+ if re.search(pattern, upper_sql):
163
+ return False, f"Query contains a forbidden keyword: {kw}"
164
+
165
+ return True, ""
166
+
167
+
168
+ def _find_top_level_select(sql: str):
169
+ """Find the match object for the outermost SELECT keyword (i.e. the
170
+ one that isn't nested inside any parentheses — which is where a
171
+ CTE's own SELECT lives). Returns None if not found.
172
+
173
+ SQL Server does NOT allow `WITH ... AS (...)` inside a subquery
174
+ (e.g. `SELECT TOP N * FROM ( WITH x AS (...) SELECT ... ) AS t`
175
+ is a syntax error) — the WITH clause must start the batch. So for
176
+ CTE queries we can't just wrap the whole statement; we have to
177
+ inject TOP N into the final SELECT in place, leaving the WITH
178
+ clause where it is.
179
+ """
180
+ depth = 0
181
+ for m in re.finditer(r"\(|\)|\bSELECT\b", sql, flags=re.IGNORECASE):
182
+ token = m.group()
183
+ if token == "(":
184
+ depth += 1
185
+ elif token == ")":
186
+ depth -= 1
187
+ elif depth == 0:
188
+ return m
189
+ return None
190
+
191
+
192
+ def enforce_row_limit(sql: str, max_rows: int) -> str:
193
+ """Cap the result set size by injecting TOP N into the outermost
194
+ SELECT — the final one, not one nested inside a CTE's own
195
+ parentheses. Works for plain SELECT statements and for
196
+ WITH ... AS (...) SELECT ... (CTE) statements alike.
197
+ """
198
+ upper_sql = sql.upper()
199
+
200
+ # If the model already limited the result set itself, leave it alone.
201
+ if re.search(r"\bTOP\s+\d+\b", upper_sql):
202
+ return sql
203
+
204
+ match = _find_top_level_select(sql)
205
+ if match is None:
206
+ # Couldn't locate a top-level SELECT (shouldn't normally happen
207
+ # since is_query_safe already required SELECT/WITH) — leave the
208
+ # query as-is rather than risk producing invalid SQL.
209
+ return sql
210
+
211
+ insert_at = match.end()
212
+
213
+ # T-SQL clause order is SELECT [ALL | DISTINCT] TOP (n) ...,
214
+ # so if DISTINCT/ALL immediately follows SELECT, TOP must go after it.
215
+ modifier_match = re.match(r"\s+(DISTINCT|ALL)\b", sql[insert_at:], flags=re.IGNORECASE)
216
+ if modifier_match:
217
+ insert_at += modifier_match.end()
218
+
219
+ return f"{sql[:insert_at]} TOP {max_rows}{sql[insert_at:]}"
220
+
221
+
222
+ def reset_query_results():
223
+ """Clear last query/results — called whenever the DB connection changes."""
224
+ st.session_state.sql = None
225
+ st.session_state.df = None
226
+
227
+
228
+ # -----------------------------
229
+ # Session State
230
+ # -----------------------------
231
+
232
+ for key, default in [
233
+ ("engine", None),
234
+ ("schema", None),
235
+ ("conn_label", None),
236
+ ("df", None),
237
+ ("sql", None),
238
+ ]:
239
+ if key not in st.session_state:
240
+ st.session_state[key] = default
241
+
242
+ # -----------------------------
243
+ # Sidebar — Connection Panel
244
+ # -----------------------------
245
+
246
+ with st.sidebar:
247
+
248
+ st.header("🔌 Database Connection")
249
+
250
+ with st.form("connection_form"):
251
+
252
+ server_input = st.text_input("Server", value=default_server)
253
+ database_input = st.text_input("Database", value=default_database)
254
+ driver_input = st.text_input("Driver", value=default_driver)
255
+
256
+ auth_type = st.radio(
257
+ "Authentication",
258
+ ["Windows (Trusted)", "SQL Login"],
259
+ horizontal=True,
260
+ )
261
+
262
+ username_input = ""
263
+ password_input = ""
264
+
265
+ if auth_type == "SQL Login":
266
+ username_input = st.text_input("Username")
267
+ password_input = st.text_input("Password", type="password")
268
+
269
+ connect_clicked = st.form_submit_button("Connect")
270
+
271
+ if connect_clicked:
272
+
273
+ if not server_input.strip() or not database_input.strip():
274
+ st.error("Server and Database are required.")
275
+
276
+ elif auth_type == "SQL Login" and (not username_input.strip() or not password_input):
277
+ st.error("Username and Password are required for SQL Login.")
278
+
279
+ else:
280
+
281
+ conn_string = build_connection_string(
282
+ server_input.strip(),
283
+ database_input.strip(),
284
+ driver_input.strip(),
285
+ auth_type,
286
+ username_input.strip(),
287
+ password_input,
288
+ )
289
+
290
+ try:
291
+ with st.spinner("Connecting..."):
292
+ new_engine = create_and_test_engine(conn_string)
293
+ new_schema = load_schema(new_engine)
294
+
295
+ st.session_state.engine = new_engine
296
+ st.session_state.schema = new_schema
297
+ st.session_state.conn_label = f"{server_input} / {database_input}"
298
+
299
+ reset_query_results()
300
+
301
+ st.success(f"Connected to {database_input} on {server_input}")
302
+ st.rerun()
303
+
304
+ except Exception as e:
305
+ logger.exception("Database connection failed")
306
+ st.error("Connection failed. Check Server / Database / Driver / credentials.")
307
+ st.exception(e)
308
+
309
+ st.divider()
310
+
311
+ if st.session_state.engine is not None:
312
+
313
+ st.header("Database")
314
+ st.write(st.session_state.conn_label)
315
+
316
+ st.header("Tables")
317
+
318
+ try:
319
+ tables = load_tables(st.session_state.engine)
320
+ st.dataframe(tables, use_container_width=True)
321
+ except Exception as e:
322
+ st.warning("Could not load table list.")
323
+ st.exception(e)
324
+
325
+ else:
326
+ st.info("Not connected. Fill in the form above and click Connect.")
327
+
328
+ # -----------------------------
329
+ # Main Area
330
+ # -----------------------------
331
+
332
+ st.title("🤖 AI SQL Assistant")
333
+
334
+ st.caption("Ask questions in natural language.")
335
+
336
+ if st.session_state.engine is None:
337
+ st.info("👈 Connect to a database in the sidebar to get started.")
338
+ st.stop()
339
+
340
+ engine = st.session_state.engine
341
+ schema = st.session_state.schema
342
+
343
+ question = st.text_input(
344
+ "Ask your database",
345
+ placeholder="Example : Show department wise salary"
346
+ )
347
+
348
+ # -----------------------------
349
+ # Button
350
+ # -----------------------------
351
+
352
+ if st.button("Run"):
353
+
354
+ if question.strip() == "":
355
+ st.warning("Please enter a question.")
356
+ st.stop()
357
+
358
+ prompt = f"""
359
+ You are a SQL Server Expert.
360
+
361
+ Database Schema
362
+
363
+ {schema}
364
+
365
+ Rules
366
+
367
+ 1. Return ONLY SQL.
368
+ 2. SQL Server syntax only.
369
+ 3. Use SELECT statements only (a WITH ... SELECT CTE is allowed).
370
+ 4. Never use INSERT.
371
+ 5. Never use UPDATE.
372
+ 6. Never use DELETE.
373
+ 7. Never use DROP.
374
+ 8. Never use ALTER.
375
+ 9. Never explain.
376
+ 10. Never use markdown.
377
+
378
+ Question
379
+
380
+ {question}
381
+ """
382
+
383
+ # -----------------------------
384
+ # Generate SQL
385
+ # -----------------------------
386
+
387
+ try:
388
+
389
+ with st.spinner("Generating SQL..."):
390
+ response = client.models.generate_content(
391
+ model=GEMINI_MODEL,
392
+ contents=prompt
393
+ )
394
+
395
+ sql = response.text.strip()
396
+
397
+ sql = sql.replace("```sql", "")
398
+ sql = sql.replace("```", "")
399
+ sql = sql.strip()
400
+
401
+ except Exception as e:
402
+
403
+ logger.exception("Gemini generation failed")
404
+ st.error("Gemini Error")
405
+ st.exception(e)
406
+ st.stop()
407
+
408
+ # -----------------------------
409
+ # Safety Check
410
+ # -----------------------------
411
+
412
+ safe, reason = is_query_safe(sql)
413
+
414
+ if not safe:
415
+
416
+ st.session_state.sql = sql
417
+ st.session_state.df = None
418
+
419
+ st.subheader("Generated SQL")
420
+ st.code(sql, language="sql")
421
+
422
+ st.error(reason)
423
+
424
+ st.stop()
425
+
426
+ sql = enforce_row_limit(sql, MAX_ROWS)
427
+
428
+ # -----------------------------
429
+ # Execute SQL
430
+ # -----------------------------
431
+
432
+ try:
433
+
434
+ with st.spinner("Running query..."):
435
+ df = pd.read_sql(text(sql), engine)
436
+
437
+ except Exception as e:
438
+
439
+ logger.exception("SQL execution failed: %s", sql)
440
+
441
+ st.session_state.sql = sql
442
+ st.session_state.df = None
443
+
444
+ st.subheader("Generated SQL")
445
+ st.code(sql, language="sql")
446
+
447
+ st.error("SQL Error")
448
+
449
+ st.exception(e)
450
+
451
+ st.stop()
452
+
453
+ # -----------------------------
454
+ # Save results to session state
455
+ # -----------------------------
456
+
457
+ st.session_state.sql = sql
458
+ st.session_state.df = df
459
+
460
+ # -----------------------------
461
+ # Display SQL + Result (persisted via session state
462
+ # so switching chart type below doesn't clear it)
463
+ # -----------------------------
464
+
465
+ if st.session_state.sql is not None:
466
+
467
+ st.subheader("Generated SQL")
468
+
469
+ st.code(st.session_state.sql, language="sql")
470
+
471
+ if st.session_state.df is not None:
472
+
473
+ df = st.session_state.df
474
+
475
+ st.subheader("Result")
476
+
477
+ if df.empty:
478
+
479
+ st.info("No records found.")
480
+
481
+ else:
482
+
483
+ st.dataframe(df, use_container_width=True)
484
+
485
+ # -----------------------------
486
+ # Chart Type Switcher
487
+ # -----------------------------
488
+
489
+ numeric = list(df.select_dtypes(include="number").columns)
490
+
491
+ text_cols = list(
492
+ df.select_dtypes(include=["object", "category"]).columns
493
+ )
494
+
495
+ all_cols = list(df.columns)
496
+
497
+ if len(numeric) > 0 and len(all_cols) > 0:
498
+
499
+ st.subheader("Chart")
500
+
501
+ chart_col1, chart_col2, chart_col3 = st.columns(3)
502
+
503
+ with chart_col1:
504
+ chart_type = st.selectbox(
505
+ "Chart type",
506
+ ["Bar", "Line", "Area", "Scatter", "Pie", "Histogram", "Box"],
507
+ key="chart_type"
508
+ )
509
+
510
+ # sensible defaults, same logic as before
511
+ default_x = text_cols[0] if text_cols else all_cols[0]
512
+ default_y = numeric[0]
513
+ default_y2 = numeric[1] if len(numeric) >= 2 else numeric[0]
514
+
515
+ x_axis = default_x
516
+ y_axis = default_y
517
+
518
+ if chart_type in ["Bar", "Line", "Area"]:
519
+
520
+ with chart_col2:
521
+ x_axis = st.selectbox("X-axis", all_cols, index=all_cols.index(default_x))
522
+
523
+ with chart_col3:
524
+ y_axis = st.selectbox("Y-axis", numeric, index=numeric.index(default_y))
525
+
526
+ elif chart_type == "Scatter":
527
+
528
+ with chart_col2:
529
+ x_axis = st.selectbox(
530
+ "X-axis", numeric,
531
+ index=numeric.index(default_y)
532
+ )
533
+
534
+ with chart_col3:
535
+ y_axis = st.selectbox(
536
+ "Y-axis", numeric,
537
+ index=numeric.index(default_y2)
538
+ )
539
+
540
+ elif chart_type == "Pie":
541
+
542
+ with chart_col2:
543
+ x_axis = st.selectbox("Labels", all_cols, index=all_cols.index(default_x))
544
+
545
+ with chart_col3:
546
+ y_axis = st.selectbox("Values", numeric, index=numeric.index(default_y))
547
+
548
+ elif chart_type in ["Histogram", "Box"]:
549
+
550
+ with chart_col2:
551
+ y_axis = st.selectbox("Column", numeric, index=numeric.index(default_y))
552
+
553
+ # -----------------------------
554
+ # Build the selected chart
555
+ # -----------------------------
556
+
557
+ fig = None
558
+
559
+ try:
560
+
561
+ if chart_type == "Bar":
562
+ fig = px.bar(df, x=x_axis, y=y_axis, title=f"{y_axis} by {x_axis}")
563
+
564
+ elif chart_type == "Line":
565
+ fig = px.line(df, x=x_axis, y=y_axis, title=f"{y_axis} by {x_axis}")
566
+
567
+ elif chart_type == "Area":
568
+ fig = px.area(df, x=x_axis, y=y_axis, title=f"{y_axis} by {x_axis}")
569
+
570
+ elif chart_type == "Scatter":
571
+ fig = px.scatter(df, x=x_axis, y=y_axis, title=f"{y_axis} vs {x_axis}")
572
+
573
+ elif chart_type == "Pie":
574
+ fig = px.pie(df, names=x_axis, values=y_axis, title=f"{y_axis} by {x_axis}")
575
+
576
+ elif chart_type == "Histogram":
577
+ fig = px.histogram(df, x=y_axis, title=f"Distribution of {y_axis}")
578
+
579
+ elif chart_type == "Box":
580
+ fig = px.box(df, y=y_axis, title=f"Box plot of {y_axis}")
581
+
582
+ if fig is not None:
583
+ st.plotly_chart(fig, use_container_width=True)
584
+
585
+ except Exception as e:
586
+ st.warning("Could not render this chart for the selected columns.")
587
+ st.exception(e)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ google-genai
2
+ python-dotenv
3
+ sqlalchemy
4
+ pyodbc
5
+ pandas
6
+ plotly
7
+ streamlit