Add text-to-sql module: schema prompt through a restricted role, JSqlParser guard, read-only role with column grants and timeout, row cap, execution-based evaluation on a real PostgreSQL

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JXVi2GMQ7bR5EmbUFdDj7N
This commit is contained in:
Claude
2026-10-09 07:22:28 +00:00
parent d67ac0630b
commit 85a3359186
26 changed files with 1179 additions and 0 deletions
@@ -0,0 +1,24 @@
package com.ankurm.texttosql;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
/** Connection settings for the throwaway PostgreSQL started by scripts/pg-up.sh. */
public final class Db {
public static final String URL = "jdbc:postgresql://127.0.0.1:" + System.getProperty("t2s.port", "5451") + "/t2s";
private Db() {
}
/** The role the assistant uses: SELECT on four tables, read-only by default, 2 second statement timeout. */
public static Connection reader() throws SQLException {
return DriverManager.getConnection(URL, "t2s_reader", "reader");
}
/** A superuser. Used only to set things up and to show what happens WITHOUT the read-only role. */
public static Connection admin() throws SQLException {
return DriverManager.getConnection(URL, "t2s_admin", "admin");
}
}
@@ -0,0 +1,56 @@
package com.ankurm.texttosql;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/** Runs one query and returns at most {@code maxRows} rows, saying whether it stopped early. */
public final class QueryRunner {
public record Rows(List<String> columns, List<List<String>> rows, boolean truncated) {
}
private QueryRunner() {
}
public static Rows run(Connection c, String sql, int maxRows) throws SQLException {
c.setAutoCommit(false); // lets the JDBC driver ask the server for only maxRows + 1 rows
try (Statement st = c.createStatement()) {
st.setMaxRows(maxRows + 1);
boolean hasResult = st.execute(sql);
while (!hasResult && st.getUpdateCount() != -1) {
hasResult = st.getMoreResults();
}
if (!hasResult) {
return new Rows(List.of(), List.of(), false);
}
try (ResultSet rs = st.getResultSet()) {
ResultSetMetaData md = rs.getMetaData();
List<String> cols = new ArrayList<>();
for (int i = 1; i <= md.getColumnCount(); i++) {
cols.add(md.getColumnLabel(i));
}
List<List<String>> out = new ArrayList<>();
boolean truncated = false;
while (rs.next()) {
if (out.size() == maxRows) {
truncated = true;
break;
}
List<String> row = new ArrayList<>();
for (int i = 1; i <= cols.size(); i++) {
row.add(rs.getString(i));
}
out.add(row);
}
return new Rows(cols, out, truncated);
}
} finally {
c.rollback();
}
}
}
@@ -0,0 +1,71 @@
package com.ankurm.texttosql;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* Builds the schema section of the prompt from the database itself (information_schema plus the
* COMMENTs on tables and columns), for an explicit list of tables. A table that is not in the list
* is not described, so the model is never shown that it exists.
*/
public final class SchemaPrompt {
private SchemaPrompt() {
}
public static String describe(Connection c, List<String> tables) throws SQLException {
StringBuilder sb = new StringBuilder();
for (String table : tables) {
String tableComment = scalar(c, "SELECT obj_description(?::regclass, 'pg_class')", table);
sb.append("TABLE ").append(table);
if (tableComment != null) {
sb.append(" -- ").append(tableComment);
}
sb.append('\n');
try (PreparedStatement ps = c.prepareStatement(
"SELECT column_name, data_type, col_description(?::regclass, ordinal_position) "
+ "FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? "
+ "ORDER BY ordinal_position")) {
ps.setString(1, table);
ps.setString(2, table);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
sb.append(" ").append(rs.getString(1)).append(' ').append(rs.getString(2));
if (rs.getString(3) != null) {
sb.append(" -- ").append(rs.getString(3));
}
sb.append('\n');
}
}
}
}
return sb.toString();
}
private static String scalar(Connection c, String sql, String arg) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, arg);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
public static List<String> columnNames(Connection c, String table) throws SQLException {
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(
"SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name=? ORDER BY ordinal_position")) {
ps.setString(1, table);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(rs.getString(1));
}
}
}
return out;
}
}
@@ -0,0 +1,124 @@
package com.ankurm.texttosql;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Function;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.Statements;
import net.sf.jsqlparser.statement.select.ParenthesedSelect;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.TableFunction;
import net.sf.jsqlparser.statement.select.WithItem;
import net.sf.jsqlparser.util.TablesNamesFinder;
/**
* Decides, BEFORE anything reaches the database, whether a model-written string is acceptable:
* exactly one statement, a plain SELECT, no SELECT INTO or row locks, no data-modifying WITH,
* only listed tables, only listed functions. Anything it cannot parse is rejected (fail closed).
*
* <p>This is a seat belt, not the wall. The wall is the database role. The two are tested together.
*/
public final class SqlGuard {
public record Verdict(boolean ok, String reason) {
static Verdict yes() {
return new Verdict(true, "ok");
}
static Verdict no(String why) {
return new Verdict(false, why);
}
}
public static final Set<String> DEFAULT_FUNCTIONS = Set.of("count", "sum", "avg", "min", "max", "round", "coalesce",
"nullif", "lower", "upper", "length", "date_trunc", "to_char", "abs", "row_number", "rank", "dense_rank");
private final Set<String> tables;
private final Set<String> functions;
public SqlGuard(Set<String> allowedTables, Set<String> allowedFunctions) {
this.tables = lower(allowedTables);
this.functions = lower(allowedFunctions);
}
public Verdict check(String sql) {
Statements parsed;
try {
parsed = CCJSqlParserUtil.parseStatements(sql);
} catch (JSQLParserException e) {
return Verdict.no("not parseable as a single SQL statement");
}
if (parsed.size() != 1) {
return Verdict.no("more than one statement (" + parsed.size() + ")");
}
Statement st = parsed.get(0);
if (!(st instanceof Select select)) {
return Verdict.no("only SELECT is allowed, found " + st.getClass().getSimpleName());
}
Set<String> cteNames = new HashSet<>();
if (select.getWithItemsList() != null) {
for (WithItem<?> w : select.getWithItemsList()) {
if (!(w.getParenthesedStatement() instanceof ParenthesedSelect)) {
return Verdict.no("WITH item is not a SELECT (data-modifying CTE)");
}
cteNames.add(w.getAlias().getName().toLowerCase(Locale.ROOT));
}
}
if (select instanceof PlainSelect ps) {
if (ps.getIntoTables() != null && !ps.getIntoTables().isEmpty()) {
return Verdict.no("SELECT INTO creates a table");
}
if (ps.getForMode() != null) {
return Verdict.no("row locking clause (" + ps.getForMode() + ")");
}
}
Set<String> usedFunctions = new HashSet<>();
TablesNamesFinder<Void> finder = new TablesNamesFinder<>() {
@Override
public <S> Void visit(Function function, S context) {
usedFunctions.add(function.getName().toLowerCase(Locale.ROOT));
return super.visit(function, context);
}
@Override
public <S> Void visit(TableFunction tableFunction, S context) {
usedFunctions.add(tableFunction.getFunction().getName().toLowerCase(Locale.ROOT));
return super.visit(tableFunction, context);
}
};
Set<String> used;
try {
used = finder.getTables(st);
} catch (RuntimeException e) {
return Verdict.no("could not analyse the statement: " + e.getClass().getSimpleName());
}
for (String t : used) {
String name = t.toLowerCase(Locale.ROOT).replace("\"", "");
if (name.startsWith("public.")) {
name = name.substring("public.".length());
}
if (!tables.contains(name) && !cteNames.contains(name)) {
return Verdict.no("table " + t + " is not allowed");
}
}
for (String f : usedFunctions) {
if (!functions.contains(f)) {
return Verdict.no("function " + f + " is not allowed");
}
}
return Verdict.yes();
}
private static Set<String> lower(Set<String> in) {
Set<String> out = new HashSet<>();
in.forEach(s -> out.add(s.toLowerCase(Locale.ROOT)));
return out;
}
}
@@ -0,0 +1,71 @@
package com.ankurm.texttosql;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
/** question -> SQL (model) -> guard -> database (read-only role) -> at most N rows. */
public class TextToSql {
public static final List<String> TABLES = List.of("customers", "products", "orders", "order_items");
public record Answer(String sql, String rejectedBy, String databaseError, QueryRunner.Rows rows) {
public boolean answered() {
return rows != null;
}
}
private final ChatClient client;
private final SqlGuard guard;
private final String system;
private final int maxRows;
public TextToSql(ChatClient client, SqlGuard guard, String schema, int maxRows) {
this.client = client;
this.guard = guard;
this.maxRows = maxRows;
this.system = """
You translate a question into ONE PostgreSQL SELECT statement.
Use only the tables and columns below. Return only the SQL, no explanation, no markdown.
Never write INSERT, UPDATE, DELETE, DDL or more than one statement.
""" + schema;
}
public String systemPrompt() {
return system;
}
public Answer answer(String question, Connection db) {
String raw = client.prompt().system(system).user(question).call().content();
String sql = extractSql(raw);
SqlGuard.Verdict verdict = guard.check(sql);
if (!verdict.ok()) {
return new Answer(sql, verdict.reason(), null, null);
}
try {
return new Answer(sql, null, null, QueryRunner.run(db, sql, maxRows));
} catch (SQLException e) {
return new Answer(sql, null, firstLine(e.getMessage()), null);
}
}
/** Models like to wrap SQL in a markdown fence even when told not to. */
public static String extractSql(String raw) {
String s = raw == null ? "" : raw.strip();
if (s.startsWith("```")) {
s = s.replaceFirst("^```[a-zA-Z]*\\s*", "").replaceFirst("\\s*```\\s*$", "");
}
return s.strip();
}
static String firstLine(String message) {
return message == null ? "" : message.lines().findFirst().orElse("").strip();
}
}
@@ -0,0 +1,88 @@
package com.ankurm.texttosql;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import com.ankurm.texttosql.support.Transcript;
import org.junit.jupiter.api.Test;
/**
* Hostile or merely careless SQL, as a model might write it, against four setups on a REAL PostgreSQL:
* neither protection (a superuser connection), the parser guard only, the read-only role only, both.
* Everything runs inside a transaction that is rolled back.
*/
class AttackMatrixTest {
record Attack(String name, String sql, boolean harmful) {
}
static final List<Attack> ATTACKS = List.of(
new Attack("DELETE FROM order_items", "DELETE FROM order_items", true),
new Attack("second statement drops a table", "SELECT * FROM orders; DROP TABLE order_items", true),
new Attack("table the assistant must not see", "SELECT * FROM api_keys", true),
new Attack("email column of an allowed table", "SELECT email FROM customers", true),
new Attack("system catalog: list database roles", "SELECT usename FROM pg_user", true),
new Attack("pg_sleep(3)", "SELECT pg_sleep(3)", true),
new Attack("read a server file", "SELECT pg_read_file('/etc/passwd')", true),
new Attack("data-modifying CTE", "WITH d AS (DELETE FROM order_items RETURNING *) SELECT count(*) FROM d", true),
new Attack("row locks on every order", "SELECT * FROM orders FOR UPDATE", true),
new Attack("SELECT INTO makes a table", "SELECT * INTO newtab FROM orders", true),
new Attack("2 billion generated rows", "SELECT count(*) FROM generate_series(1, 2000000000)", true),
new Attack("turn read-only off for the session", "SELECT set_config('default_transaction_read_only', 'off', false)", true),
new Attack("cartesian product, allowed names only", "SELECT count(*) FROM orders a, orders b, orders c, orders d", true),
new Attack("(legit) join and group", "SELECT c.country, count(*) FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.country", false),
new Attack("(legit) harmless comment", "SELECT 1 /* ; DROP TABLE orders */", false),
new Attack("(legit) date_part, not on the list", "SELECT date_part('month', ordered_at) FROM orders LIMIT 5", false));
static String run(Attack a, boolean admin, boolean guard) {
if (guard && !GuardCasesTest.GUARD.check(a.sql()).ok()) {
return "guard";
}
try (Connection c = admin ? Db.admin() : Db.reader()) {
if (admin) {
try (var st = c.createStatement()) {
st.execute("SET statement_timeout = '4s'"); // test harness limit so the suite finishes
}
}
QueryRunner.run(c, a.sql(), 100);
return a.harmful() ? "HARM" : "ok";
} catch (SQLException e) {
if (admin && e.getMessage().contains("statement timeout")) {
return "HARM"; // it was still running when the harness stopped it
}
return "db";
}
}
@Test
void fourSetups() throws Exception {
try (Transcript t = new Transcript("02-attack-matrix.txt", "Sixteen queries against four setups on a real PostgreSQL 16")) {
t.line("%-44s %-8s %-12s %-10s %s", "query", "neither", "guard only", "role only", "both");
int[] harm = new int[4];
List<String> roleErrors = new ArrayList<>();
for (Attack a : ATTACKS) {
String[] cells = { run(a, true, false), run(a, true, true), run(a, false, false), run(a, false, true) };
for (int i = 0; i < 4; i++) {
harm[i] += cells[i].equals("HARM") ? 1 : 0;
}
t.line("%-44s %-8s %-12s %-10s %s", a.name(), cells[0], cells[1], cells[2], cells[3]);
if (cells[2].equals("db")) {
try (Connection c = Db.reader()) {
QueryRunner.run(c, a.sql(), 100);
} catch (SQLException e) {
roleErrors.add(String.format("%-44s %s", a.name(), TextToSql.firstLine(e.getMessage())));
}
}
}
t.blank().line("harmful outcomes out of 13 harmful queries: neither %d, guard only %d, role only %d, both %d", harm[0], harm[1], harm[2], harm[3]);
t.blank().line("what the database said to the read-only role:");
roleErrors.forEach(l -> t.line(" %s", l));
assertThat(harm[0]).isEqualTo(13);
assertThat(harm[3]).isZero();
}
}
}
@@ -0,0 +1,114 @@
package com.ankurm.texttosql;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.ankurm.texttosql.support.ScriptedModel;
import com.ankurm.texttosql.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
/**
* Evaluation by execution: run the reference query and the model's query and compare RESULTS.
* The "model" is a script of eight answers I wrote (some right, some wrong in ways models are), so
* these numbers demonstrate the metric, not any model's ability.
*/
class EvaluationTest {
record Case(String question, String reference, String modelSql, boolean ordered) {
}
static final List<Case> CASES = List.of(
new Case("How many customers are in Germany?",
"SELECT count(*) FROM customers WHERE country = 'DE'",
"SELECT COUNT(*) AS n FROM customers WHERE country = 'DE'", false),
new Case("What is the total revenue of paid orders?",
"SELECT sum(oi.quantity * p.price) FROM orders o JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id WHERE o.status = 'paid'",
"SELECT SUM(p.price * i.quantity) AS revenue FROM order_items i JOIN products p ON p.id = i.product_id WHERE i.order_id IN (SELECT id FROM orders WHERE status = 'paid')", false),
new Case("Which 3 products sold the most units?",
"SELECT p.name, sum(oi.quantity) AS units FROM products p JOIN order_items oi ON oi.product_id = p.id GROUP BY p.name ORDER BY units DESC, p.name LIMIT 3",
"SELECT p.name, SUM(quantity) FROM order_items JOIN products p ON p.id = product_id GROUP BY p.name ORDER BY SUM(quantity) DESC, p.name LIMIT 3", true),
new Case("Which customers have never ordered?",
"SELECT id FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) ORDER BY id",
"SELECT c.id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL ORDER BY c.id", true),
new Case("How many orders are there per status?",
"SELECT status, count(*) FROM orders GROUP BY status",
"SELECT o.status, count(*) FROM orders o JOIN order_items i ON i.order_id = o.id GROUP BY o.status", false),
new Case("What is the average order value?",
"SELECT avg(t) FROM (SELECT sum(oi.quantity * p.price) AS t FROM order_items oi JOIN products p ON p.id = oi.product_id GROUP BY oi.order_id) x",
"SELECT avg(price) FROM products", false),
new Case("What is the revenue by country?",
"SELECT c.country, sum(oi.quantity * p.price) FROM customers c JOIN orders o ON o.customer_id = c.id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id GROUP BY c.country",
"SELECT country, sum(amount) FROM sales GROUP BY country", false),
new Case("How many orders were placed in March 2025?",
"SELECT count(*) FROM orders WHERE ordered_at >= DATE '2025-03-01' AND ordered_at < DATE '2025-04-01'",
"SELECT count(*) FROM orders WHERE to_char(ordered_at, 'YYYY-MM') = '2025-03'", false));
static String norm(String s) {
return s.toLowerCase().replaceAll("\\s+", " ").replaceAll("\\s*;\\s*$", "").strip();
}
static List<List<String>> canonical(QueryRunner.Rows rows, boolean ordered) {
List<List<String>> out = new ArrayList<>();
for (List<String> r : rows.rows()) {
List<String> cells = new ArrayList<>();
for (String v : r) {
try {
cells.add(new BigDecimal(v).stripTrailingZeros().toPlainString());
} catch (RuntimeException e) {
cells.add(v);
}
}
out.add(cells);
}
if (!ordered) {
out.sort((a, b) -> a.toString().compareTo(b.toString()));
}
return out;
}
@Test
void executionMatchBeatsStringMatch() throws Exception {
Map<String, String> script = new LinkedHashMap<>();
CASES.forEach(c -> script.put(c.question(), "```sql\n" + c.modelSql() + "\n```"));
ScriptedModel model = new ScriptedModel(script::get);
try (Connection db = Db.reader(); Transcript t = new Transcript("06-evaluation.txt",
"Eight questions, scored three ways (the model is a script, not a language model)")) {
String schema = SchemaPrompt.describe(db, TextToSql.TABLES);
TextToSql service = new TextToSql(ChatClient.create(model), GuardCasesTest.GUARD, schema, 100);
t.line("%-46s %-8s %-10s %s", "question", "string", "execution", "what happened");
int string = 0, exec = 0, refused = 0, silentWrong = 0;
for (Case c : CASES) {
TextToSql.Answer a = service.answer(c.question(), db);
boolean sameString = norm(a.sql()).equals(norm(c.reference()));
boolean sameResult = false;
String note;
if (a.rejectedBy() != null) {
note = "refused by the guard: " + a.rejectedBy();
refused++;
} else {
QueryRunner.Rows ref = QueryRunner.run(db, c.reference(), 100);
sameResult = canonical(a.rows(), c.ordered()).equals(canonical(ref, c.ordered()));
note = sameResult ? "same result" : "DIFFERENT result: got " + a.rows().rows().getFirst() + ", expected " + ref.rows().getFirst();
silentWrong += sameResult ? 0 : 1;
}
string += sameString ? 1 : 0;
exec += sameResult ? 1 : 0;
t.line("%-46s %-8s %-10s %s", c.question(), sameString ? "match" : "no", sameResult ? "match" : "no", note);
}
t.blank().line("string match: %d/8 execution match: %d/8 refused before running: %d ran and returned a wrong answer: %d", string, exec, refused, silentWrong);
assertThat(string).isZero();
assertThat(exec).isEqualTo(5);
assertThat(silentWrong).isEqualTo(2);
assertThat(Set.of(refused)).containsExactly(1);
}
}
}
@@ -0,0 +1,52 @@
package com.ankurm.texttosql;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import com.ankurm.texttosql.support.Transcript;
import org.junit.jupiter.api.Test;
/** What the parser-based guard accepts and rejects, including legitimate queries it wrongly refuses. */
class GuardCasesTest {
static final SqlGuard GUARD = new SqlGuard(Set.copyOf(TextToSql.TABLES), SqlGuard.DEFAULT_FUNCTIONS);
@Test
void acceptedAndRejected() {
Map<String, Boolean> cases = new LinkedHashMap<>();
// should pass
cases.put("SELECT count(*) FROM orders WHERE status = 'paid'", true);
cases.put("SELECT c.country, count(*) FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.country ORDER BY 2 DESC", true);
cases.put("WITH big AS (SELECT order_id FROM order_items GROUP BY order_id HAVING sum(quantity) > 5) SELECT count(*) FROM big", true);
cases.put("SELECT * FROM orders WHERE id IN (SELECT order_id FROM order_items WHERE quantity > 2)", true);
cases.put("SELECT 1 /* ; DROP TABLE orders */", true);
cases.put("SELECT name FROM public.customers", true);
// should be refused
cases.put("DELETE FROM orders", false);
cases.put("SELECT * FROM orders; DROP TABLE orders", false);
cases.put("SELECT * FROM api_keys", false);
cases.put("SELECT usename FROM pg_user", false);
cases.put("SELECT pg_sleep(3)", false);
cases.put("SELECT pg_read_file('/etc/passwd')", false);
cases.put("WITH d AS (DELETE FROM orders RETURNING *) SELECT count(*) FROM d", false);
cases.put("SELECT * FROM orders FOR UPDATE", false);
cases.put("SELECT * INTO newtab FROM orders", false);
cases.put("SELECT count(*) FROM generate_series(1, 2000000000)", false);
cases.put("SELECT set_config('default_transaction_read_only', 'off', false)", false);
cases.put("SELECT * FROM orders o, \"api_keys\" k", false);
// legitimate but refused (cost of an allow-list)
cases.put("SELECT date_part('month', ordered_at) FROM orders", false);
cases.put("SELECT now()", false);
try (Transcript t = new Transcript("03-guard-cases.txt", "What the parser guard accepts and refuses")) {
for (Map.Entry<String, Boolean> e : cases.entrySet()) {
SqlGuard.Verdict v = GUARD.check(e.getKey());
t.line("%-8s %-110s %s", v.ok() ? "ACCEPT" : "REFUSE", e.getKey().length() > 108 ? e.getKey().substring(0, 105) + "..." : e.getKey(),
v.ok() ? "" : "(" + v.reason() + ")");
assertThat(v.ok()).as(e.getKey()).isEqualTo(e.getValue());
}
}
}
}
@@ -0,0 +1,57 @@
package com.ankurm.texttosql;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.SQLException;
import com.ankurm.texttosql.support.Transcript;
import org.junit.jupiter.api.Test;
/** Row cap and time limit, enforced below the guard: these are the database's and the driver's job. */
class LimitsTest {
@Test
void rowCapAndTimeout() throws Exception {
try (Connection c = Db.reader(); Transcript t = new Transcript("04-limits.txt", "Row cap and statement timeout (cap = 100 rows)")) {
QueryRunner.Rows many = QueryRunner.run(c, "SELECT id FROM orders ORDER BY id", 100);
t.line("1000-row table, cap 100: rows returned %d, truncated %s", many.rows().size(), many.truncated());
QueryRunner.Rows exact = QueryRunner.run(c, "SELECT id FROM orders WHERE id <= 100 ORDER BY id", 100);
t.line("exactly 100 matching rows, cap 100: rows returned %d, truncated %s", exact.rows().size(), exact.truncated());
QueryRunner.Rows few = QueryRunner.run(c, "SELECT id FROM orders WHERE id <= 7", 100);
t.line("7 matching rows, cap 100: rows returned %d, truncated %s", few.rows().size(), few.truncated());
assertThat(many.rows()).hasSize(100);
assertThat(many.truncated()).isTrue();
assertThat(exact.truncated()).isFalse();
long t0 = System.nanoTime();
QueryRunner.Rows lazy = QueryRunner.run(c, "SELECT a.id FROM orders a, orders b, orders c", 100);
long lazyMs = (System.nanoTime() - t0) / 1_000_000;
t.line("a billion-row join, cap 100: rows returned %d, truncated %s (the server stopped producing rows)", lazy.rows().size(), lazy.truncated());
assertThat(lazy.rows()).hasSize(100);
assertThat(lazyMs).isLessThan(1500);
String srf = "";
try {
QueryRunner.run(c, "SELECT * FROM generate_series(1, 2000000000)", 100);
} catch (SQLException e) {
srf = TextToSql.firstLine(e.getMessage());
}
t.line("2 billion generated rows, cap 100: %s (the row cap did not help here)", srf);
assertThat(srf).contains("statement timeout");
long t1 = System.nanoTime();
String error = "";
try {
QueryRunner.run(c, "SELECT count(*) FROM generate_series(1, 2000000000)", 100);
} catch (SQLException e) {
error = TextToSql.firstLine(e.getMessage());
}
long ms = (System.nanoTime() - t1) / 1_000_000;
t.line("count(*) over 2 billion rows: %s", error);
t.line("stopped within 5 seconds of starting: %s (role setting statement_timeout = 2s)", ms < 5000);
assertThat(error).contains("statement timeout");
assertThat(ms).isBetween(1500L, 5000L);
}
}
}
@@ -0,0 +1,51 @@
package com.ankurm.texttosql;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import com.ankurm.texttosql.support.Transcript;
import org.junit.jupiter.api.Test;
/** The read-only role has two independent walls: a session default that can be switched off, and privileges that cannot. */
class RoleTest {
private static String attempt(Statement st, String sql) {
try {
if (st.execute(sql) && st.getResultSet() != null) {
try (ResultSet rs = st.getResultSet()) {
rs.next();
return "ok: " + rs.getString(1);
}
}
return "ok";
} catch (SQLException e) {
return TextToSql.firstLine(e.getMessage());
}
}
@Test
void twoWalls() throws Exception {
try (Connection c = Db.reader(); Statement st = c.createStatement(); Transcript t = new Transcript("05-role.txt", "What the t2s_reader role can and cannot do")) {
c.setAutoCommit(true);
t.line("%-62s %s", "SHOW statement_timeout", attempt(st, "SHOW statement_timeout"));
t.line("%-62s %s", "SHOW default_transaction_read_only", attempt(st, "SHOW default_transaction_read_only"));
t.line("%-62s %s", "SELECT count(*) FROM orders", attempt(st, "SELECT count(*) FROM orders"));
String insert1 = attempt(st, "INSERT INTO orders VALUES (9999, 1, 'paid', DATE '2025-01-01')");
t.line("%-62s %s", "INSERT INTO orders ... (wall 1: read-only default)", insert1);
t.line("%-62s %s", "SELECT set_config('default_transaction_read_only','off',false)",
attempt(st, "SELECT set_config('default_transaction_read_only','off',false)"));
t.line("%-62s %s", "SHOW default_transaction_read_only", attempt(st, "SHOW default_transaction_read_only"));
String insert2 = attempt(st, "INSERT INTO orders VALUES (9999, 1, 'paid', DATE '2025-01-01')");
t.line("%-62s %s", "INSERT INTO orders ... (wall 2: no INSERT privilege)", insert2);
t.line("%-62s %s", "SELECT count(*) FROM api_keys", attempt(st, "SELECT count(*) FROM api_keys"));
t.line("%-62s %s", "SELECT email FROM customers", attempt(st, "SELECT email FROM customers"));
t.line("%-62s %s", "SELECT id, name FROM customers LIMIT 1", attempt(st, "SELECT id FROM customers LIMIT 1"));
assertThat(insert1).contains("read-only transaction");
assertThat(insert2).contains("permission denied for table orders");
}
}
}
@@ -0,0 +1,31 @@
package com.ankurm.texttosql;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import com.ankurm.texttosql.support.ScriptedModel;
import com.ankurm.texttosql.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
/** The schema section of the prompt is built from the database, through the same role the assistant uses. */
class SchemaPromptTest {
@Test
void promptShowsOnlyWhatTheRoleMaySee() throws Exception {
try (Connection db = Db.reader(); Transcript t = new Transcript("01-schema-prompt.txt", "The prompt the model receives")) {
String schema = SchemaPrompt.describe(db, TextToSql.TABLES);
ScriptedModel model = new ScriptedModel(q -> "SELECT count(*) FROM customers");
TextToSql service = new TextToSql(ChatClient.create(model), GuardCasesTest.GUARD, schema, 100);
service.answer("How many customers do we have?", db);
String sent = model.systemPrompts.getFirst();
for (String line : sent.strip().split("\\R")) {
t.line("| %s", line);
}
t.blank().line("user message: %s", model.questions.getFirst());
t.line("mentions api_keys: %s | mentions customers.email: %s", sent.contains("api_keys"), sent.contains("email"));
assertThat(sent).doesNotContain("api_keys").doesNotContain("email").contains("TABLE orders").contains("one of: paid, shipped");
}
}
}
@@ -0,0 +1,45 @@
package com.ankurm.texttosql.support;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
/**
* A "model" that answers from a script: question in, SQL out. It is NOT a language model and says
* nothing about how well one writes SQL; it stands for "a model produced this string", good or bad,
* so the code AROUND the model can be tested. It also records what it was sent.
*/
public class ScriptedModel implements ChatModel {
private final Function<String, String> script;
public final List<String> systemPrompts = new ArrayList<>();
public final List<String> questions = new ArrayList<>();
public ScriptedModel(Function<String, String> script) {
this.script = script;
}
@Override
public ChatResponse call(Prompt prompt) {
String question = "";
for (Message m : prompt.getInstructions()) {
if (m.getMessageType() == MessageType.SYSTEM) {
systemPrompts.add(m.getText());
} else if (m.getMessageType() == MessageType.USER) {
question = m.getText();
}
}
questions.add(question);
return new ChatResponse(List.of(new Generation(new AssistantMessage(script.apply(question)))));
}
}
@@ -0,0 +1,47 @@
package com.ankurm.texttosql.support;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and
* echoes it to the console. Every console block quoted in the article comes out of one of these
* files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}