Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Result-row cap** with a 100 / 500 / 1000 / 5000 / 10000 selector in the result
toolbar (default **500**, a global preference persisted across tabs and reloads).
A normal `SELECT` now fetches at most the selected cap rather than pulling every
row over the wire: ClickHouse stops cleanly at the cap server-side
(`max_result_rows` + `result_overflow_mode = 'break'`), a small client-side guard
trims the block-boundary overage `break` can leave, and a **"first N (capped)"**
badge appears in the stats row when the limit is hit. Changing the selector
re-runs the current query, so raising the cap genuinely fetches more. The display
grid now renders up to the selected cap (10000 actually shows 10000). EXPLAIN /
PIPELINE / ESTIMATE runs are exempt. (#86)
- Playwright e2e now runs on **WebKit** in addition to Chromium and Firefox, so
many Safari regressions on the `html{zoom}`-based layout fail CI instead of
shipping silently. README gained a **Supported browsers** stance: desktop
Expand Down
19 changes: 16 additions & 3 deletions src/core/stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
// `applyStreamLine` folds one parsed object into a mutable result; keeping it
// pure (no fetch, no DOM) makes the streaming parser fully unit-testable.

/** A fresh, empty result object for a query run in output format `fmt`. */
export function newResult(fmt) {
/**
* A fresh, empty result object for a query run in output format `fmt`. `rowLimit`
* (default 0 = uncapped) is the client-side row cap: the server's
* result_overflow_mode='break' stops at the cap but can overshoot to the next
* block boundary, so applyStreamLine trims any rows past `rowLimit` and flags
* `capped` once it's reached.
*/
export function newResult(fmt, rowLimit = 0) {
return {
columns: [],
rows: [],
Expand All @@ -18,6 +24,8 @@ export function newResult(fmt) {
error: null,
cancelled: false,
pct: 0,
rowLimit,
capped: false,
};
}

Expand All @@ -26,7 +34,12 @@ export function applyStreamLine(json, result) {
if (json.meta) {
result.columns = json.meta.map((m) => ({ name: m.name, type: m.type }));
} else if (json.row) {
result.rows.push(result.columns.map((c) => json.row[c.name]));
// At the cap: drop the row (block-boundary overage from `break`) and flag it.
if (result.rowLimit > 0 && result.rows.length >= result.rowLimit) {
result.capped = true;
} else {
result.rows.push(result.columns.map((c) => json.row[c.name]));
}
} else if (json.progress) {
const p = json.progress;
const total = +p.total_rows_to_read || 0;
Expand Down
14 changes: 12 additions & 2 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export async function loadEntityDoc(ctx, name, sqlString) {
*
* @param ctx
* @param sql
* @param o { format, signal, onLine(json), onChunk(), onRaw(text) }
* @param o { format, signal, resultRowLimit, onLine(json), onChunk(), onRaw(text) }
*/
export async function runQuery(ctx, sql, o = {}) {
const fmt = o.format || 'Table';
Expand All @@ -383,14 +383,24 @@ export async function runQuery(ctx, sql, o = {}) {
: fmt === 'TSV'
? 'TabSeparatedWithNamesAndTypes'
: fmt;
// Cap a normal result query server-side: max_result_rows stops the read at N
// and result_overflow_mode='break' makes ClickHouse stop cleanly at a block
// boundary (no error, no further data pulled) rather than throwing. The caller
// decides scope — it passes resultRowLimit for normal SELECTs (Table + explicit
// FORMAT) and 0 for EXPLAIN/PIPELINE/ESTIMATE (which also run as 'Table', so the
// exemption can't be told apart by format here). `break` can overshoot by up to
// a block on the streaming path, which the applyStreamLine guard trims.
const cap = o.resultRowLimit > 0
? { max_result_rows: o.resultRowLimit, result_overflow_mode: 'break' }
: {};
const url = chUrl(ctx.origin, {
format: fmtParam,
// wait_end_of_query buffers the whole response server-side so the HTTP
// status reflects errors — but it defeats progressive streaming (first rows
// wait for the query to finish: ~16s vs ~0.5s on a 1.3M-row scan). Keep it
// only for raw modes (read whole anyway); the streaming Table path drops it
// and surfaces mid-stream errors via the in-band `exception` line instead.
extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), add_http_cors_header: 1 },
extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), ...cap, add_http_cors_header: 1 },
// Tagging the request with a query_id lets Cancel issue KILL QUERY for it.
params: o.queryId ? { query_id: o.queryId } : {},
});
Expand Down
17 changes: 17 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,20 @@ export const KEYS = {
saved: 'asb:saved',
history: 'asb:history',
libraryName: 'asb:libraryName',
resultRowLimit: 'asb:resultRowLimit',
};

/** Row-limit options for the result cap selector (shared between state + UI). */
export const RESULT_ROW_LIMIT_OPTIONS = [100, 500, 1000, 5000, 10000];

/** Default row cap when none is persisted (or a stored value is unrecognized). */
export const DEFAULT_RESULT_ROW_LIMIT = 500;

/** Snap a row-limit to a known option, falling back to the default. Pure. */
export function normalizeRowLimit(n) {
return RESULT_ROW_LIMIT_OPTIONS.includes(n) ? n : DEFAULT_RESULT_ROW_LIMIT;
}

/** Default name for a fresh / unnamed saved-query library. */
export const DEFAULT_LIBRARY_NAME = 'SQL Library';

Expand All @@ -46,6 +58,11 @@ export function createState(read = { loadJSON, loadStr }) {
nextTabId: 2,
theme: read.loadStr(KEYS.theme, 'light'),
density: 'comfortable',
// Global cap on how many rows a normal SELECT fetches (server-side
// max_result_rows + a client-side guard; see runQuery / applyStreamLine).
// One persisted preference, default 500; a non-option stored value snaps
// back to the default so the selector always reflects a real choice.
resultRowLimit: normalizeRowLimit(parseInt(read.loadStr(KEYS.resultRowLimit, '500'), 10)),
sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420),
editorPct: num(KEYS.editorPct, 45, 15, 85),
sideSplitPct: num(KEYS.sideSplitPct, 58, 25, 85),
Expand Down
20 changes: 20 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,26 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
padding: 2px 7px; border-radius: 4px;
background: color-mix(in oklab, #ef4444 12%, transparent);
}
.capped-badge {
font-family: var(--mono); font-size: 10.5px; color: var(--accent);
padding: 2px 7px; border-radius: 4px;
background: color-mix(in oklab, var(--accent) 12%, transparent);
white-space: nowrap;
}

/* Result row-cap selector (result toolbar, after the view tabs). */
.row-limit { display: flex; align-items: center; gap: 6px; }
.row-limit-label {
font-size: 10.5px; color: var(--fg-faint);
text-transform: uppercase; letter-spacing: .05em;
}
.row-limit-select {
height: 24px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: 5px; color: var(--fg);
font-family: inherit; font-size: 11.5px;
padding: 0 6px; outline: none; cursor: pointer;
}

/* scrollbars */
*::-webkit-scrollbar { width: 10px; height: 10px; }
Expand Down
20 changes: 18 additions & 2 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { h, zoomScale, fixedAnchor } from './dom.js';
import { Icon } from './icons.js';
import {
createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart,
createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart, normalizeRowLimit,
} from '../state.js';
import { saveJSON, saveStr } from '../core/storage.js';
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
Expand Down Expand Up @@ -471,8 +471,13 @@ export function createApp(env = {}) {
fmt = explicitFmt || 'Table';
}

// Cap a normal result query (Table or explicit-FORMAT SELECT) at the global
// row limit; EXPLAIN/PIPELINE/ESTIMATE are exempt (small output, and a cap
// would truncate a plan oddly). The streaming guard reads it off the result;
// runQuery adds the server-side max_result_rows for the Table path.
const rowLimit = explainMode ? 0 : app.state.resultRowLimit;
const t0 = now();
tab.result = newResult(fmt);
tab.result = newResult(fmt, rowLimit);
if (explainView) tab.result.explainView = explainView;
app.state.resultSort = { col: null, dir: 'asc' };
app.state.runT0 = t0;
Expand All @@ -494,6 +499,7 @@ export function createApp(env = {}) {
try {
const out = await ch.runQuery(chCtx, runSql, {
format: fmt,
resultRowLimit: rowLimit,
queryId: app.state.runQueryId,
signal: app.state.abortController.signal,
onLine: (json) => applyStreamLine(json, tab.result),
Expand Down Expand Up @@ -652,6 +658,15 @@ export function createApp(env = {}) {
function explainQuery() { return run({ explain: true }); }
// Switch the active EXPLAIN view (re-runs the derived query, keeps the mode).
function setExplainView(id) { return run({ explainView: id }); }
// Change the global result-row cap: persist the (normalized) preference and
// re-run the current query so a raise genuinely fetches more (server-side cap),
// a lower one stops sooner. run() no-ops on an empty editor, so changing the
// limit with nothing typed just saves the preference.
function setResultRowLimit(n) {
app.state.resultRowLimit = normalizeRowLimit(n);
app.savePref('resultRowLimit', app.state.resultRowLimit);
return run();
}

// Fetch the DDL for `target` (e.g. 'db.table' or 'DATABASE db') with
// SHOW CREATE, pretty-print it through formatQuery(), and drop it into the
Expand Down Expand Up @@ -863,6 +878,7 @@ export function createApp(env = {}) {
formatQuery,
explainQuery,
setExplainView,
setResultRowLimit,
showSchemaGraph,
expandSchemaGraph,
openNodeDetail,
Expand Down
53 changes: 48 additions & 5 deletions src/ui/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { looksLikeHtml, prettyValue } from '../core/cell.js';
import { sortRows } from '../core/sort.js';
import { autoChart, schemaKey, chartFieldOptions, chartColors, chartJsConfig, chartCfgValid, normalizeChartCfg, unzoomChartEvent, CHART_ROW_CAP } from '../core/chart-data.js';
import { EXPLAIN_VIEWS } from '../core/explain.js';
import { RESULT_ROW_LIMIT_OPTIONS } from '../state.js';
import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph } from './explain-graph.js';

// View id → tab glyph for the EXPLAIN view strip (kept here so core/explain.js
Expand All @@ -18,9 +19,19 @@ const EXPLAIN_ICONS = {
pipeline: Icon.share, estimate: Icon.rows,
};

const VIS_CAP = 5000;
const VIS_CAP = 5000; // fallback display cap for results that carry no row limit (raw / EXPLAIN)
const MIN_COL = 48; // px floor for a resized column

/**
* How many rows to render: follow the result's own row cap when set (so a 10000
* limit renders 10000), else the fixed fallback. The server cap already trims a
* normal SELECT to its limit, so this just keeps the renderers from re-capping
* a large-but-allowed result. Pure — exported for tests.
*/
export function visCap(r) {
return r.rowLimit > 0 ? r.rowLimit : VIS_CAP;
}

/**
* New width (px) for a column dragged by `dx` client px. `scale` converts client
* px → CSS px under the page `zoom` (computed per element); 0/NaN falls back to
Expand Down Expand Up @@ -149,6 +160,27 @@ function streamStrip(r) {
: h('i', { class: 'sweep' }));
}

/**
* A <select> capping how many rows a normal query fetches (the global, persisted
* preference). Changing it re-runs the current query with the new server-side
* cap, so a higher limit genuinely fetches more. The caller hides it for EXPLAIN
* views (small output a cap would truncate oddly).
*/
function rowLimitSelect(app) {
const sel = h('select', {
class: 'row-limit-select',
title: 'Max rows to fetch — changing re-runs the query',
onchange: (e) => app.actions.setResultRowLimit(Number(e.target.value)),
});
for (const n of RESULT_ROW_LIMIT_OPTIONS) {
sel.appendChild(h('option', { value: String(n) }, String(n)));
}
// Reflect the current limit by value (set after the options are attached so the
// <select> resolves the selection correctly).
sel.value = String(app.state.resultRowLimit);
return h('label', { class: 'row-limit' }, h('span', { class: 'row-limit-label' }, 'Rows'), sel);
}

function buildToolbar(app, r) {
const toolbar = h('div', { class: 'res-toolbar' });
if (r && r.schemaGraph) {
Expand Down Expand Up @@ -196,6 +228,9 @@ function buildToolbar(app, r) {
}
}
toolbar.appendChild(tabs);
// Row-cap selector after the view tabs, for normal result queries only —
// EXPLAIN views are exempt (small output a cap would truncate oddly).
if (!(r && r.explainView)) toolbar.appendChild(rowLimitSelect(app));
toolbar.appendChild(h('div', { style: { flex: '1' } }));
// EXPLAIN views suppress the ms/rows/bytes stats — they're not meaningful for a
// plan and the freed space lets the five tabs breathe.
Expand Down Expand Up @@ -226,6 +261,13 @@ function buildToolbar(app, r) {
h('span', { class: 'v' }, (r.rawText != null ? '—' : r.rows.length) + ' rows')));
toolbar.appendChild(h('div', { class: 'stat', title: r.progress.rows + ' rows scanned' },
h('span', { class: 'ic' }, Icon.bytes()), h('span', { class: 'v' }, formatBytes(r.progress.bytes))));
// The result hit the row cap: say so (the fetch stopped at the limit, more
// rows exist). Only the streaming path sets `capped`; raw output can't.
if (r.capped) {
toolbar.appendChild(h('span', {
class: 'capped-badge', title: 'Fetch stopped at the row limit — raise it to see more',
}, 'first ' + r.rowLimit + ' (capped)'));
}
}
if (r.explainView === 'pipeline' && r.rawText && !r.error) {
toolbar.appendChild(h('button', {
Expand All @@ -248,7 +290,7 @@ function buildToolbar(app, r) {
}

export function renderJson(r) {
const arr = r.rows.slice(0, VIS_CAP).map((row) => {
const arr = r.rows.slice(0, visCap(r)).map((row) => {
const o = {};
r.columns.forEach((c, i) => { o[c.name] = row[i]; });
return o;
Expand Down Expand Up @@ -294,7 +336,8 @@ export function renderTable(app, r) {
if (Object.keys(r.colWidths).length) applyFixedWidths(table, r);

const tbody = document.createElement('tbody');
rows.slice(0, VIS_CAP).forEach((row, ri) => {
const cap = visCap(r);
rows.slice(0, cap).forEach((row, ri) => {
const tr = document.createElement('tr');
tr.appendChild(h('td', { class: 'idx' }, String(ri + 1)));
row.forEach((v, ci) => {
Expand All @@ -312,10 +355,10 @@ export function renderTable(app, r) {
});
table.appendChild(tbody);
wrap.appendChild(table);
if (rows.length > VIS_CAP) {
if (rows.length > cap) {
wrap.appendChild(h('div', {
style: { padding: '10px 14px', fontSize: '11px', color: 'var(--fg-faint)', fontFamily: 'var(--mono)', borderTop: '1px solid var(--border)' },
}, '… + ' + (rows.length - VIS_CAP) + ' more rows truncated for display.'));
}, '… + ' + (rows.length - cap) + ' more rows truncated for display.'));
}
return wrap;
}
Expand Down
1 change: 1 addition & 0 deletions tests/helpers/fake-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function makeApp(over = {}) {
formatQuery: vi.fn(),
explainQuery: vi.fn(),
setExplainView: vi.fn(),
setResultRowLimit: vi.fn(),
showSchemaGraph: vi.fn(),
expandSchemaGraph: vi.fn(),
openNodeDetail: vi.fn(),
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,45 @@ describe('query run', () => {
await app.actions.run();
expect(app.activeTab().result.rawFormat).toBe('JSON'); // FORMAT clause, not the EXPLAIN default
});
const runUrl = (e, re) => e.fetch.mock.calls.findLast((c) => re.test((c[1] && c[1].body) || ''))[0];
it('caps a normal SELECT server-side and trims block-boundary overage (flagging capped)', async () => {
const { app, e } = appForRun([
[(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody([
'{"meta":[{"name":"a","type":"UInt8"}]}\n',
'{"row":{"a":"1"}}\n', '{"row":{"a":"2"}}\n', '{"row":{"a":"3"}}\n', // overage past the cap of 2
]) })],
]);
app.state.resultRowLimit = 2;
app.activeTab().sql = 'SELECT 1';
await app.actions.run();
const url = runUrl(e, /SELECT 1/);
expect(url).toContain('max_result_rows=2');
expect(url).toContain('result_overflow_mode=break');
expect(app.activeTab().result.rows).toEqual([['1'], ['2']]); // overage trimmed client-side
expect(app.activeTab().result.capped).toBe(true);
});
it('does not cap EXPLAIN/ESTIMATE runs even though ESTIMATE streams as Table', async () => {
const { app, e } = appForRun([
[(u, sql) => /ESTIMATE/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"rows","type":"UInt64"}]}\n', '{"row":{"rows":"42"}}\n']) })],
]);
app.state.resultRowLimit = 100;
app.activeTab().sql = 'EXPLAIN ESTIMATE SELECT 1';
await app.actions.run();
expect(runUrl(e, /ESTIMATE/)).not.toContain('max_result_rows');
expect(app.activeTab().result.capped).toBe(false);
});
it('setResultRowLimit persists the normalized preference and re-runs with the new cap', async () => {
const { app, e } = appForRun([
[(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"a","type":"UInt8"}]}\n', '{"row":{"a":"1"}}\n']) })],
]);
app.activeTab().sql = 'SELECT 1';
await app.actions.setResultRowLimit(99); // not an option → snaps back to the default 500
expect(app.state.resultRowLimit).toBe(500);
expect(globalThis.localStorage.getItem('asb:resultRowLimit')).toBe('500');
await app.actions.setResultRowLimit(1000);
expect(app.state.resultRowLimit).toBe(1000);
expect(runUrl(e, /SELECT 1/)).toContain('max_result_rows=1000'); // re-ran with the new cap
});
});

describe('formatQuery', () => {
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/ch-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,16 @@ describe('runQuery', () => {
await runQuery(raw, 'x', { format: 'TSV' });
expect(raw.fetch.mock.calls[0][0]).toContain('wait_end_of_query=1');
});
it('adds the server-side row cap when resultRowLimit is set; omits it otherwise', async () => {
const capped = ctxWith(async () => streamResp(['{"row":{}}\n']));
await runQuery(capped, 'x', { format: 'Table', resultRowLimit: 500 });
const url = capped.fetch.mock.calls[0][0];
expect(url).toContain('max_result_rows=500');
expect(url).toContain('result_overflow_mode=break');
const uncapped = ctxWith(async () => streamResp(['{"row":{}}\n']));
await runQuery(uncapped, 'x', { format: 'Table' }); // no limit → no cap params
expect(uncapped.fetch.mock.calls[0][0]).not.toContain('max_result_rows');
});
});

describe('killQuery', () => {
Expand Down
Loading