Skip to content

Cap the rows a query returns

Run in production

Add a runCapped() helper that stops reading a query’s rows at a cap you choose, keeps the query’s ORDER BY, and tells you when the result was cut.

The SQL ask() returns has no row limit. Your application caps the rows, so that a question like “show me every row in this 100M-row table”, which is valid SQL, doesn’t load the whole table into your server and your UI.

  • A connection that runs AskDB’s SQL with a read-only role and a statement timeout. Run safely in production covers both.

Read one row more than you show, then stop. If that extra row arrives, the result was cut: show the rows you kept and say that there are more. Stop reading in the driver, as in the tabs below, instead of rewriting the SQL. This keeps the query’s ORDER BY on every engine, so a “top 10” question still gets the top 10.

Each tab defines runCapped(sql, params), which returns { rows, truncated }:

Open a cursor for the statement inside a read-only transaction, and fetch one row more than the cap. DECLARE works only inside a transaction, and a cursor returns the rows in the statement’s own order. With queryMode: "extended", pg sends the statement on its own even when params is empty, so a stacked second statement fails instead of running. The error listener keeps a dropped connection from crashing the process: the running query fails with the error instead.

import { Pool } from "pg";
const ROW_CAP = 1000;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
pool.on("error", (err) => console.error({ err }, "idle database client error"));
async function runCapped(sql: string, params: unknown[] = []) {
const client = await pool.connect();
const onError = () => undefined;
client.on("error", onError);
try {
await client.query("BEGIN READ ONLY");
// A variable, not an inline object: @types/pg doesn't declare queryMode.
const declare = { text: `DECLARE askdb_rows NO SCROLL CURSOR FOR ${sql}`, values: params, queryMode: "extended" };
await client.query(declare);
const { rows } = await client.query(`FETCH ${ROW_CAP + 1} FROM askdb_rows`);
return { rows: rows.slice(0, ROW_CAP), truncated: rows.length > ROW_CAP };
} finally {
await client.query("ROLLBACK").catch(() => undefined);
client.off("error", onError);
client.release();
}
}

Pass result.sql and the parameters that go with it. Every tab takes the SQL as ask() returns it, with the trailing ; that AskDB keeps. Only the wrapper needs it removed.

With a tenant scope, ask() returns the tenant IDs in result.tenantParams, in the order of the parameter markers in result.sql. That’s the default tenantSqlMode, "sql-params" (see Multi-tenancy). Pass them as the parameters:

// askdb initialized at startup with createAskDb() — see Embed in a Node app.
const result = await askdb.ask(question, { tenantScope });
const { rows, truncated } = await runCapped(result.sql, result.tenantParams);

Without a tenant scope, or with tenantSqlMode: "sql-only", the result has no tenantParams. Pass the SQL alone:

const result = await askdb.ask(question);
const { rows, truncated } = await runCapped(result.sql);

When truncated is true, show the rows you kept and say that the result has more. For example, ask the reader to narrow the question. Don’t present a cut result as the whole answer: a total or a “no more rows” summary of it would be wrong.

If you can’t stop part way: wrap the SQL

Section titled “If you can’t stop part way: wrap the SQL”

If your query helper can’t open a cursor or stop part way, for example an ORM’s raw-query method, you can wrap the statement in a LIMIT on Postgres and SQLite instead.

The SQL ask() returns can end with a ;, because AskDB keeps the model’s single trailing ;. A ; inside the subquery is a syntax error on every engine, so remove it before you wrap the statement:

const statement = result.sql.replace(/;$/, "").trimEnd();
const cappedSql = `SELECT * FROM (${statement}) AS askdb_q LIMIT ${ROW_CAP + 1}`;

ask() returns the SQL trimmed, and its read-only checks (validateSelectSql) allow a ; only at the end of the statement, so this removes the terminator, and any space before it, and nothing else. Avoid a pattern such as /\s*;$/: it backtracks on a long run of whitespace, so a reply padded with one can stall your server.