Generowanie i walidowanie zapytań SQL
Wzorce promptów dla bezpiecznego SQL: tryb tylko SELECT i zapytania parametryzowane.
Generowanie i walidowanie zapytań SQL to bezpłatna lekcja AI Agents na CoddyKit. To lekcja 3 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej AI Agents, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs AI Agents zawiera 4 lekcji w sumie.
Cel generowania zapytania SQL
Wygenerowanie zapytania SQL to dopiero połowa zadania. Przed wykonaniem go na rzeczywistej bazie danych należy zweryfikować, czy zapytanie jest bezpieczne, poprawne składniowo i dokładnie realizuje intencje użytkownika.
W tej lekcji omówiono wymuszanie trybu tylko SELECT, parsowanie, bezpieczne wykonywanie oraz weryfikację planu wykonania.
Wymuszanie trybu tylko SELECT
Najbardziej niebezpiecznym działaniem, jakie może wykonać agent NL-to-SQL, jest wykonanie destrukcyjnego polecenia. Niezależnie od tego, co zwróci LLM, zawsze należy wymuszać tryb tylko SELECT.
Proste sprawdzanie ciągu znaków jest niewystarczające — należy użyć właściwego parsera SQL.
import sqlparse
def is_select_only(sql):
parsed = sqlparse.parse(sql)
if not parsed:
return False
for statement in parsed:
stmt_type = statement.get_type()
if stmt_type != 'SELECT':
print(f'Blocked statement type: {stmt_type}')
return False
return True
# Test
print(is_select_only('SELECT * FROM users')) # True
print(is_select_only('DROP TABLE users')) # False — BlockedLista blokowanych słów kluczowych jako dodatkowa warstwa ochrony
Nawet korzystając z sqlparse, należy dodać listę blokowanych słów kluczowych jako dodatkową warstwę ochrony. Niektóre wstrzyknięcia SQL mogą oszukać parsery. Sprawdzanie niebezpiecznych słów kluczowych przed wykonaniem zapytania zapewnia dodatkowy poziom bezpieczeństwa.
DANGEROUS_KEYWORDS = [
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE',
'ALTER', 'TRUNCATE', 'GRANT', 'REVOKE', 'EXEC',
'EXECUTE', 'CALL', 'MERGE'
]
def passes_blocklist(sql):
sql_upper = sql.upper()
for keyword in DANGEROUS_KEYWORDS:
# Check as whole word to avoid false positives like 'CREATED_AT'
import re
if re.search(r'\b' + keyword + r'\b', sql_upper):
raise ValueError(f'Blocked keyword detected: {keyword}')
return True
def validate_sql(sql):
if not is_select_only(sql):
raise ValueError('Only SELECT statements are allowed')
passes_blocklist(sql)
return TrueParsowanie SQL za pomocą sqlparse
sqlparse tokenizuje i parsuje ciągi SQL bez ich wykonywania. Można sprawdzać strukturę zapytania, wyodrębniać nazwy tabel i wykrywać problemy ze składnią.
Zainstaluj za pomocą pip install sqlparse.
import sqlparse
from sqlparse.sql import IdentifierList, Identifier
from sqlparse.tokens import Keyword, DML
def extract_table_names(sql):
parsed = sqlparse.parse(sql)[0]
tables = []
from_seen = False
for token in parsed.tokens:
if token.ttype is DML and token.value.upper() == 'SELECT':
continue
if token.ttype is Keyword and token.value.upper() in ('FROM', 'JOIN'):
from_seen = True
continue
if from_seen:
if isinstance(token, Identifier):
tables.append(token.get_name())
elif isinstance(token, IdentifierList):
for item in token.get_identifiers():
tables.append(item.get_name())
from_seen = False
return tables
print(extract_table_names('SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id'))
# ['users', 'orders']Weryfikowanie istnienia tabel w schemacie
Po wyodrębnieniu nazw tabel z wygenerowanego zapytania SQL należy porównać je ze znanym schematem. Jeśli LLM zmyślił nazwę tabeli, należy odrzucić zapytanie przed jego wykonaniem, zamiast otrzymywać niejasny błąd bazy danych.
def validate_tables_exist(sql, known_tables):
used_tables = extract_table_names(sql)
invalid = [t for t in used_tables if t and t not in known_tables]
if invalid:
raise ValueError(
f'Query references non-existent tables: {invalid}. '
f'Available tables: {list(known_tables)[:10]}...'
)
return True
# Usage
known = set(build_schema_dict(conn).keys())
try:
validate_tables_exist(generated_sql, known)
except ValueError as e:
# Send error back to LLM for correction
corrected_sql = llm_fix_sql(generated_sql, str(e))
print('Corrected SQL:', corrected_sql)Wykonywanie zapytań z parametrami
Nigdy nie należy używać formatowania ciągów znaków do wstawiania do SQL wartości podanych przez użytkownika. Mimo że zapytanie generuje LLM, wszelkie wartości filtrów podane przez użytkownika należy przekazywać jako parametry, aby zapobiec wstrzyknięciom SQL.
import sqlite3
conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE orders (status TEXT, user_id INTEGER)')
conn.execute("INSERT INTO orders VALUES ('pending', 42)")
def safe_execute(conn, sql_template, params=()):
"""Execute with parameterized values."""
cur = conn.cursor()
cur.execute(sql_template, params) # driver handles escaping
columns = [d[0] for d in cur.description]
rows = cur.fetchmany(200)
return {'columns': columns, 'rows': rows}
sql = 'SELECT * FROM orders WHERE status = ? AND user_id = ?'
result = safe_execute(conn, sql, params=('pending', 42))
print(result)Plan EXPLAIN przed wykonaniem
W przypadku kosztownych zapytań dotyczących dużych tabel należy uruchomić EXPLAIN przed właściwym zapytaniem. Jeśli plan wykonania wskazuje na pełne skanowanie tabeli zawierającej milion wierszy, należy ostrzec użytkownika lub odrzucić zapytanie.
def check_explain_plan(conn, sql):
explain_sql = f'EXPLAIN {sql}'
with conn.cursor() as cur:
cur.execute(explain_sql)
plan = '\n'.join(row[0] for row in cur.fetchall())
# Check for sequential scans on large tables
if 'Seq Scan' in plan:
print('WARNING: Query involves a sequential scan')
print(plan)
return {'safe': False, 'plan': plan, 'warning': 'Sequential scan detected'}
return {'safe': True, 'plan': plan}
# Use before executing
plan_result = check_explain_plan(conn, generated_sql)
if not plan_result['safe']:
print(f'Optimization hint: {plan_result["warning"]}')Wymuszanie limitu wierszy
LLM może wygenerować SELECT * FROM logs bez klauzuli LIMIT, co potencjalnie zwróci miliony wierszy. Zawsze należy wymuszać maksymalną liczbę wierszy — dodając LIMIT do zapytania lub pobierając ograniczony zbiór wyników.
import re
MAX_ROWS = 500
def enforce_row_limit(sql, max_rows=MAX_ROWS):
sql_upper = sql.upper().rstrip().rstrip(';')
# Check if LIMIT already present
if re.search(r'\bLIMIT\b', sql_upper):
# Extract current limit and enforce maximum
match = re.search(r'LIMIT\s+(\d+)', sql_upper)
if match:
current = int(match.group(1))
if current > max_rows:
sql = re.sub(r'LIMIT\s+\d+', f'LIMIT {max_rows}', sql, flags=re.IGNORECASE)
else:
sql = sql.rstrip(';') + f' LIMIT {max_rows}'
return sql
print(enforce_row_limit('SELECT * FROM users'))
# SELECT * FROM users LIMIT 500Wyodrębnianie czystego SQL z danych wyjściowych LLM
LLM często zwraca SQL opakowany w bloki kodu Markdown (```sql ... ```) lub wraz z tekstem objaśniającym. Przed parsowaniem lub wykonaniem należy wyodrębnić surowy kod SQL.
import re
CODE_FENCE = chr(96) * 3 # three backticks, built at runtime to avoid template issues
def extract_sql(llm_response):
# Remove markdown code blocks ('''sql ... ''' or ''' ... ''')
pattern = CODE_FENCE + r'(?:sql)?\s*([\s\S]+?)' + CODE_FENCE
match = re.search(pattern, llm_response, re.IGNORECASE)
if match:
return match.group(1).strip()
# If no code block, look for SELECT statement
match = re.search(r'(SELECT\s+[\s\S]+?;)', llm_response, re.IGNORECASE)
if match:
return match.group(1).strip()
# Fallback: strip common preamble phrases
cleaned = re.sub(r'^(Here is|The SQL query is|Query:)[^\n]*\n', '',
llm_response, flags=re.IGNORECASE).strip()
return cleaned
if __name__ == '__main__':
demo_response = 'Here is the SQL query:\n' + CODE_FENCE + 'sql\nSELECT * FROM users;\n' + CODE_FENCE
print(extract_sql(demo_response))
Kompletny proces walidacji
Należy połączyć wszystkie etapy walidacji w jednej funkcji, która przyjmuje surowe dane wyjściowe LLM i zwraca bezpieczny ciąg SQL możliwy do wykonania albo zgłasza błąd z opisowym komunikatem ułatwiającym jego obsługę.
def validate_and_prepare_sql(llm_output, known_tables, max_rows=500):
# Step 1: extract raw SQL
sql = extract_sql(llm_output)
if not sql:
raise ValueError('No SQL found in LLM response')
# Step 2: type check
if not is_select_only(sql):
raise ValueError('Only SELECT queries allowed')
# Step 3: keyword blocklist
passes_blocklist(sql)
# Step 4: table existence check
validate_tables_exist(sql, known_tables)
# Step 5: row limit
sql = enforce_row_limit(sql, max_rows)
return sql
# Full flow
try:
safe_sql = validate_and_prepare_sql(llm_output, known_tables)
result = safe_execute(conn, safe_sql)
except ValueError as e:
corrected = llm_fix_sql(llm_output, str(e))
safe_sql = validate_and_prepare_sql(corrected, known_tables)
result = safe_execute(conn, safe_sql)Użytkownik bazy danych tylko do odczytu
Walidacja na poziomie kodu jest ważna, ale niewystarczająca. Jako ostatnią warstwę ochrony należy łączyć się z bazą danych za pomocą konta użytkownika tylko do odczytu, które ma wyłącznie uprawnienia SELECT. Nawet jeśli złośliwe zapytanie ominie wszystkie kontrole, baza danych je odrzuci.
# Create read-only user in PostgreSQL:
# CREATE USER nl_to_sql_reader WITH PASSWORD 'secure_password';
# GRANT CONNECT ON DATABASE yourdb TO nl_to_sql_reader;
# GRANT USAGE ON SCHEMA public TO nl_to_sql_reader;
# GRANT SELECT ON ALL TABLES IN SCHEMA public TO nl_to_sql_reader;
import os
import psycopg2
def get_readonly_connection():
return psycopg2.connect(
host=os.getenv('DB_HOST'),
database=os.getenv('DB_NAME'),
user='nl_to_sql_reader', # read-only account
password=os.getenv('DB_READER_PASS')
)Sprawdzenie wiedzy
Jakie jest prawidłowe, wielowarstwowe podejście do walidacji SQL w agencie NL-to-SQL?
Podsumowanie: generowanie i walidacja SQL
Bezpieczne generowanie SQL wymaga pełnego procesu walidacji: wyodrębnienia czystego SQL z danych wyjściowych LLM, wymuszenia trybu tylko SELECT za pomocą sqlparse, zastosowania listy blokowanych słów kluczowych, zweryfikowania nazw tabel względem rzeczywistego schematu, wymuszenia limitów wierszy oraz użycia użytkownika bazy danych tylko do odczytu jako ostatecznego zabezpieczenia.
Zapytania parametryzowane chronią przed wstrzyknięciami, gdy używane są wartości podane przez użytkownika. Sprawdzanie planu EXPLAIN zapobiega uruchamianiu nieoczekiwanie kosztownych zapytań na danych produkcyjnych.
Często zadawane pytania
Czy lekcja „Generowanie i walidowanie zapytań SQL” jest bezpłatna?
Tak — pełny tekst „Generowanie i walidowanie zapytań SQL” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu AI Agents, przejdź na CoddyKit PRO. Kurs AI Agents zawiera 4 lekcji w sumie.
Co nauczysz się w „Generowanie i walidowanie zapytań SQL”?
Wzorce promptów dla bezpiecznego SQL: tryb tylko SELECT i zapytania parametryzowane. Ćwiczysz AI Agents z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.
Czy potrzebuję doświadczenia, aby zacząć AI Agents?
Nie wymagamy żadnego doświadczenia. AI Agents w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 3 z 4.
Ile czasu zajmuje lekcja „Generowanie i walidowanie zapytań SQL”?
Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.
Czy mogę pisać i uruchamiać kod w tej lekcji AI Agents?
Tak. Każda lekcja AI Agents zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.
Wszystkie lekcje w tym kursie
- Jak działają agenci NL-to-SQL
- Rozumienie i wstrzykiwanie schematu
- Generowanie i walidowanie zapytań SQL
- Obsługa niejednoznacznych pytań dotyczących baz danych