Skip to content

Commit ff49eeb

Browse files
committed
Add truncated flag to execute_sql results when max_rows cuts off rows
When a configured max_rows cap fired, the tool response was indistinguishable from a table that genuinely has exactly max_rows rows (count simply equaled the cap), so LLM consumers could silently reason from incomplete data. Detection is exact rather than heuristic: when the cap is the binding constraint, SQLRowLimiter rewrites the query to fetch max_rows + 1 rows (LIMIT/TOP probe). If the probe row comes back, the connector drops it, clamps the count to max_rows, and marks the result set truncated; the statements payload then carries "truncated": true alongside count. The flag is omitted entirely for complete results, and never fires when the query's own smaller LIMIT/TOP is what bounded the result. Applies to all five connectors (PostgreSQL, MySQL, MariaDB, SQLite, SQL Server), including per-statement flags in multi-statement batches. SQL Server now also echoes the original statement text instead of the TOP-rewritten one, matching the other connectors. Closes #404 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SxUnGXE7MRMyezpunumuFH
1 parent 10c0f86 commit ff49eeb

17 files changed

Lines changed: 495 additions & 58 deletions

docs/tools/execute-sql.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ max_rows = 1000
158158
- Only applied to SELECT statements, not INSERT/UPDATE/DELETE
159159
- If your query already has a `LIMIT` or `TOP` clause, DBHub uses the smaller value
160160
- Can be configured per-tool in [TOML configuration](/config/toml)
161+
- When the cap actually cuts off rows, the statement's result carries `"truncated": true` alongside `count`, so a capped result is distinguishable from a table that genuinely has exactly `max_rows` rows (detection is exact — DBHub fetches one probe row past the cap; the flag is omitted for complete results). Run `COUNT(*)` to get the true total when you see it.
161162

162163
## Selective Tool Exposure
163164

src/connectors/__tests__/mariadb.integration.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,8 @@ describe('MariaDB Connector Integration Tests', () => {
413413
expect(result.resultSets[0].rows).toHaveLength(2);
414414
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
415415
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
416+
// The cap provably cut off rows (users has more than 2)
417+
expect(result.resultSets[0].truncated).toBe(true);
416418
});
417419

418420
it('should respect existing LIMIT clause when lower than maxRows', async () => {
@@ -421,9 +423,11 @@ describe('MariaDB Connector Integration Tests', () => {
421423
'SELECT * FROM users ORDER BY id LIMIT 1',
422424
{ maxRows: 3 }
423425
);
424-
426+
425427
expect(result.resultSets[0].rows).toHaveLength(1);
426428
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
429+
// The user's own LIMIT fired, not the cap — no truncation flag
430+
expect(result.resultSets[0].truncated).toBeUndefined();
427431
});
428432

429433
it('should use maxRows when existing LIMIT is higher', async () => {
@@ -432,10 +436,11 @@ describe('MariaDB Connector Integration Tests', () => {
432436
'SELECT * FROM users ORDER BY id LIMIT 10',
433437
{ maxRows: 2 }
434438
);
435-
439+
436440
expect(result.resultSets[0].rows).toHaveLength(2);
437441
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
438442
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
443+
expect(result.resultSets[0].truncated).toBe(true);
439444
});
440445

441446
it('should not affect non-SELECT queries', async () => {

src/connectors/__tests__/mysql.integration.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@ describe('MySQL Connector Integration Tests', () => {
333333
expect(result.resultSets[0].rows).toHaveLength(2);
334334
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
335335
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
336+
// The cap provably cut off rows (users has more than 2)
337+
expect(result.resultSets[0].truncated).toBe(true);
336338
});
337339

338340
it('should respect existing LIMIT clause when lower than maxRows', async () => {
@@ -341,9 +343,11 @@ describe('MySQL Connector Integration Tests', () => {
341343
'SELECT * FROM users ORDER BY id LIMIT 1',
342344
{ maxRows: 3 }
343345
);
344-
346+
345347
expect(result.resultSets[0].rows).toHaveLength(1);
346348
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
349+
// The user's own LIMIT fired, not the cap — no truncation flag
350+
expect(result.resultSets[0].truncated).toBeUndefined();
347351
});
348352

349353
it('should use maxRows when existing LIMIT is higher', async () => {
@@ -352,10 +356,11 @@ describe('MySQL Connector Integration Tests', () => {
352356
'SELECT * FROM users ORDER BY id LIMIT 10',
353357
{ maxRows: 2 }
354358
);
355-
359+
356360
expect(result.resultSets[0].rows).toHaveLength(2);
357361
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
358362
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
363+
expect(result.resultSets[0].truncated).toBe(true);
359364
});
360365

361366
it('should not affect non-SELECT queries', async () => {

src/connectors/__tests__/postgres.integration.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ describe('PostgreSQL Connector Integration Tests', () => {
387387
expect(result.resultSets[0].rows).toHaveLength(2);
388388
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
389389
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
390+
// The cap provably cut off rows (users has more than 2)
391+
expect(result.resultSets[0].truncated).toBe(true);
390392
});
391393

392394
it('should respect existing LIMIT clause when lower than maxRows', async () => {
@@ -395,9 +397,11 @@ describe('PostgreSQL Connector Integration Tests', () => {
395397
'SELECT * FROM users ORDER BY id LIMIT 1',
396398
{ maxRows: 3 }
397399
);
398-
400+
399401
expect(result.resultSets[0].rows).toHaveLength(1);
400402
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
403+
// The user's own LIMIT fired, not the cap — no truncation flag
404+
expect(result.resultSets[0].truncated).toBeUndefined();
401405
});
402406

403407
it('should use maxRows when existing LIMIT is higher', async () => {
@@ -406,10 +410,11 @@ describe('PostgreSQL Connector Integration Tests', () => {
406410
'SELECT * FROM users ORDER BY id LIMIT 10',
407411
{ maxRows: 2 }
408412
);
409-
413+
410414
expect(result.resultSets[0].rows).toHaveLength(2);
411415
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
412416
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
417+
expect(result.resultSets[0].truncated).toBe(true);
413418
});
414419

415420
it('should not affect non-SELECT queries', async () => {

src/connectors/__tests__/sqlite.integration.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,10 +341,71 @@ describe('SQLite Connector Integration Tests', () => {
341341
'SELECT * FROM users ORDER BY id LIMIT 10',
342342
{ maxRows: 2 }
343343
);
344-
344+
345345
expect(result.resultSets[0].rows).toHaveLength(2);
346346
expect(result.resultSets[0].rows[0].name).toBe('John Doe');
347347
expect(result.resultSets[0].rows[1].name).toBe('Jane Smith');
348+
expect(result.resultSets[0].truncated).toBe(true);
349+
});
350+
351+
it('should flag truncated when maxRows cuts off rows', async () => {
352+
// users has 3+ rows; the cap of 2 provably cuts rows off
353+
const result = await sqliteTest.connector.executeSQL(
354+
'SELECT * FROM users ORDER BY id',
355+
{ maxRows: 2 }
356+
);
357+
358+
expect(result.resultSets[0].rows).toHaveLength(2);
359+
expect(result.resultSets[0].rowCount).toBe(2);
360+
expect(result.resultSets[0].truncated).toBe(true);
361+
// The echoed SQL is the original statement, not the probe rewrite
362+
expect(result.resultSets[0].sql).toBe('SELECT * FROM users ORDER BY id');
363+
});
364+
365+
it('should not flag truncated when the result has exactly maxRows rows', async () => {
366+
// Exactly 2 rows exist and the cap is 2 — indistinguishable by count
367+
// alone, but the probe row proves the result is complete
368+
const result = await sqliteTest.connector.executeSQL(
369+
'SELECT 1 AS n UNION ALL SELECT 2',
370+
{ maxRows: 2 }
371+
);
372+
373+
expect(result.resultSets[0].rows).toHaveLength(2);
374+
expect(result.resultSets[0].truncated).toBeUndefined();
375+
});
376+
377+
it('should not flag truncated when the result has fewer rows than maxRows', async () => {
378+
const result = await sqliteTest.connector.executeSQL(
379+
'SELECT 1 AS n',
380+
{ maxRows: 2 }
381+
);
382+
383+
expect(result.resultSets[0].rows).toHaveLength(1);
384+
expect(result.resultSets[0].truncated).toBeUndefined();
385+
});
386+
387+
it("should not flag truncated when the user's own lower LIMIT fires", async () => {
388+
// The user asked for 1 row; the cap of 3 never fired
389+
const result = await sqliteTest.connector.executeSQL(
390+
'SELECT * FROM users ORDER BY id LIMIT 1',
391+
{ maxRows: 3 }
392+
);
393+
394+
expect(result.resultSets[0].rows).toHaveLength(1);
395+
expect(result.resultSets[0].truncated).toBeUndefined();
396+
});
397+
398+
it('should flag truncated per statement in multi-statement execution', async () => {
399+
const result = await sqliteTest.connector.executeSQL(
400+
'SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3; SELECT 1 AS m',
401+
{ maxRows: 2 }
402+
);
403+
404+
expect(result.resultSets).toHaveLength(2);
405+
expect(result.resultSets[0].rows).toHaveLength(2);
406+
expect(result.resultSets[0].truncated).toBe(true);
407+
expect(result.resultSets[1].rows).toHaveLength(1);
408+
expect(result.resultSets[1].truncated).toBeUndefined();
348409
});
349410

350411
it('should not affect non-SELECT queries', async () => {

src/connectors/__tests__/sqlserver.integration.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,8 @@ describe('SQL Server Connector Integration Tests', () => {
774774
expect(result.resultSets[0].rows).toHaveLength(2);
775775
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
776776
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
777+
// The cap provably cut off rows (users has more than 2)
778+
expect(result.resultSets[0].truncated).toBe(true);
777779
});
778780

779781
it('should respect existing TOP clause when lower than maxRows', async () => {
@@ -782,9 +784,11 @@ describe('SQL Server Connector Integration Tests', () => {
782784
'SELECT TOP 1 * FROM users ORDER BY id',
783785
{ maxRows: 3 }
784786
);
785-
787+
786788
expect(result.resultSets[0].rows).toHaveLength(1);
787789
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
790+
// The user's own TOP fired, not the cap — no truncation flag
791+
expect(result.resultSets[0].truncated).toBeUndefined();
788792
});
789793

790794
it('should use maxRows when existing TOP is higher', async () => {
@@ -793,10 +797,11 @@ describe('SQL Server Connector Integration Tests', () => {
793797
'SELECT TOP 10 * FROM users ORDER BY id',
794798
{ maxRows: 2 }
795799
);
796-
800+
797801
expect(result.resultSets[0].rows).toHaveLength(2);
798802
expect(result.resultSets[0].rows[0]).toHaveProperty('name');
799803
expect(result.resultSets[0].rows[1]).toHaveProperty('name');
804+
expect(result.resultSets[0].truncated).toBe(true);
800805
});
801806

802807
it('should not affect non-SELECT queries', async () => {

src/connectors/interface.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ export interface SQLResultSet {
3636
sql?: string;
3737
rows: any[];
3838
rowCount: number;
39+
/**
40+
* True when a configured max_rows cap cut off this result set: the query
41+
* had more rows than the cap allows, and `rows`/`rowCount` reflect only the
42+
* first max_rows of them. Omitted (never false) when the result is
43+
* complete, so consumers can distinguish a capped result from a table that
44+
* genuinely has exactly max_rows rows. Detection uses a probe row (the cap
45+
* is applied as LIMIT/TOP max_rows + 1, see SQLRowLimiter.flagTruncation),
46+
* so the flag is exact, not a heuristic.
47+
*/
48+
truncated?: boolean;
3949
}
4050

4151
export interface SQLResult {

src/connectors/mariadb/index.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -653,12 +653,15 @@ export class MariaDBConnector implements Connector {
653653
// attribution below, whether or not maxRows needs to rewrite it.
654654
const statements = splitSQLStatements(sql, "mariadb");
655655
let processedSQL = sql;
656+
// Per-statement truncation-probe flags, index-aligned with `statements`
657+
let probes: boolean[] = [];
656658
if (options.maxRows) {
657-
const processedStatements = statements.map(statement =>
658-
SQLRowLimiter.applyMaxRows(statement, options.maxRows)
659+
const rewrites = statements.map(statement =>
660+
SQLRowLimiter.applyMaxRowsWithTruncationProbe(statement, options.maxRows)
659661
);
662+
probes = rewrites.map(rewrite => rewrite.probeApplied);
660663

661-
processedSQL = processedStatements.join('; ');
664+
processedSQL = rewrites.map(rewrite => rewrite.sql).join('; ');
662665
if (sql.trim().endsWith(';')) {
663666
processedSQL += ';';
664667
}
@@ -676,6 +679,15 @@ export class MariaDBConnector implements Connector {
676679
// Parse results using shared utility that handles both single and multi-statement queries
677680
const resultSets = parseQueryResultSets(results, statements);
678681

682+
// Result sets are per statement in source order, so a length match
683+
// means the pairing with the probe flags is exact (same reasoning as
684+
// the sql attribution inside parseQueryResultSets).
685+
if (resultSets.length === probes.length) {
686+
resultSets.forEach((set, index) =>
687+
SQLRowLimiter.flagTruncation(set, options.maxRows, probes[index])
688+
);
689+
}
690+
679691
return { resultSets };
680692
}
681693
);

src/connectors/mysql/index.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -683,12 +683,15 @@ export class MySQLConnector implements Connector {
683683
// attribution below, whether or not maxRows needs to rewrite it.
684684
const statements = splitSQLStatements(sql, "mysql");
685685
let processedSQL = sql;
686+
// Per-statement truncation-probe flags, index-aligned with `statements`
687+
let probes: boolean[] = [];
686688
if (options.maxRows) {
687-
const processedStatements = statements.map(statement =>
688-
SQLRowLimiter.applyMaxRows(statement, options.maxRows)
689+
const rewrites = statements.map(statement =>
690+
SQLRowLimiter.applyMaxRowsWithTruncationProbe(statement, options.maxRows)
689691
);
692+
probes = rewrites.map(rewrite => rewrite.probeApplied);
690693

691-
processedSQL = processedStatements.join('; ');
694+
processedSQL = rewrites.map(rewrite => rewrite.sql).join('; ');
692695
if (sql.trim().endsWith(';')) {
693696
processedSQL += ';';
694697
}
@@ -710,6 +713,15 @@ export class MySQLConnector implements Connector {
710713
// Parse results using shared utility that handles both single and multi-statement queries
711714
const resultSets = parseQueryResultSets(firstResult, statements);
712715

716+
// Result sets are per statement in source order, so a length match
717+
// means the pairing with the probe flags is exact (same reasoning as
718+
// the sql attribution inside parseQueryResultSets).
719+
if (resultSets.length === probes.length) {
720+
resultSets.forEach((set, index) =>
721+
SQLRowLimiter.flagTruncation(set, options.maxRows, probes[index])
722+
);
723+
}
724+
713725
return { resultSets };
714726
}
715727
);

src/connectors/postgres/index.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -679,8 +679,11 @@ export class PostgresConnector implements Connector {
679679
const statements = splitSQLStatements(sql, "postgres");
680680

681681
if (statements.length === 1) {
682-
// Single statement - apply maxRows if applicable
683-
const processedStatement = SQLRowLimiter.applyMaxRows(statements[0], options.maxRows);
682+
// Single statement - apply maxRows (with a truncation probe row) if applicable
683+
const { sql: processedStatement, probeApplied } = SQLRowLimiter.applyMaxRowsWithTruncationProbe(
684+
statements[0],
685+
options.maxRows
686+
);
684687

685688
// Engine-level read-only enforcement: when the tool is read-only, run the
686689
// statement inside a READ ONLY transaction so the database itself rejects any
@@ -692,11 +695,13 @@ export class PostgresConnector implements Connector {
692695
? await client.query(processedStatement, parameters)
693696
: await client.query(processedStatement);
694697
await client.query('COMMIT');
695-
return {
696-
resultSets: [
697-
{ sql: statements[0], rows: result.rows, rowCount: result.rowCount ?? result.rows.length },
698-
],
698+
const resultSet: SQLResultSet = {
699+
sql: statements[0],
700+
rows: result.rows,
701+
rowCount: result.rowCount ?? result.rows.length,
699702
};
703+
SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied);
704+
return { resultSets: [resultSet] };
700705
} catch (error) {
701706
// Best-effort rollback so a failed ROLLBACK (e.g. dropped connection)
702707
// can't mask the original query error.
@@ -717,11 +722,13 @@ export class PostgresConnector implements Connector {
717722
result = await client.query(processedStatement);
718723
}
719724
// Explicitly return rows and rowCount to ensure rowCount is preserved
720-
return {
721-
resultSets: [
722-
{ sql: statements[0], rows: result.rows, rowCount: result.rowCount ?? result.rows.length },
723-
],
725+
const resultSet: SQLResultSet = {
726+
sql: statements[0],
727+
rows: result.rows,
728+
rowCount: result.rowCount ?? result.rows.length,
724729
};
730+
SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied);
731+
return { resultSets: [resultSet] };
725732
} else {
726733
// Multiple statements - parameters not supported for multi-statement queries
727734
if (parameters && parameters.length > 0) {
@@ -740,15 +747,20 @@ export class PostgresConnector implements Connector {
740747
await client.query(options.readonly ? 'BEGIN READ ONLY' : 'BEGIN');
741748
try {
742749
for (let statement of statements) {
743-
// Apply maxRows limit to SELECT queries if specified
744-
const processedStatement = SQLRowLimiter.applyMaxRows(statement, options.maxRows);
750+
// Apply maxRows limit (with a truncation probe row) to SELECT queries if specified
751+
const { sql: processedStatement, probeApplied } = SQLRowLimiter.applyMaxRowsWithTruncationProbe(
752+
statement,
753+
options.maxRows
754+
);
745755

746756
const result = await client.query(processedStatement);
747-
resultSets.push({
757+
const resultSet: SQLResultSet = {
748758
sql: statement,
749759
rows: result.rows ?? [],
750760
rowCount: result.rowCount ?? result.rows?.length ?? 0,
751-
});
761+
};
762+
SQLRowLimiter.flagTruncation(resultSet, options.maxRows, probeApplied);
763+
resultSets.push(resultSet);
752764
}
753765
await client.query('COMMIT');
754766
} catch (error) {

0 commit comments

Comments
 (0)