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
48 changes: 48 additions & 0 deletions e2e/support/start-mock-trino.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ const DEFAULT_RESPONSE = {
//
// FROM paged_table QUEUED, then 3 pages of 10 rows via GET
// FROM finished_paged_table first response already FINISHED, 1 more page via GET
// FROM huge_table one page of MAX_CLIENT_ROWS + 1 rows (row limit exceeded)
// FROM exact_limit_table exactly MAX_CLIENT_ROWS rows, then an empty trailing page
// FAILS_SILENTLY first response FAILED without an error object
// FAILS_LATER one page of rows, then FAILED without an error object
// HOLD:<id> RUNNING until released via POST /__mock/release/<id>
Expand Down Expand Up @@ -188,6 +190,52 @@ function startPagedQuery(id: string, sql: string): Page | null {
});
return { id, nextUri: nextUri(id, 1), stats: { state: 'QUEUED' } };
}
if (sql.includes('FROM huge_table')) {
// First page is empty; the follow-up page exceeds MAX_CLIENT_ROWS.
queries.set(id, {
sql,
pages: [
{
id,
columns: [{ name: 'id', type: 'integer' }],
data: Array.from({ length: 10_001 }, (_, i) => [i + 1]),
nextUri: nextUri(id, 2),
stats: { state: 'RUNNING' }
}
]
});
return {
id,
columns: [{ name: 'id', type: 'integer' }],
data: [],
nextUri: nextUri(id, 1),
stats: { state: 'RUNNING' }
};
}
if (sql.includes('FROM exact_limit_table')) {
// Like Trino for `LIMIT 10000`: the page completing the result still carries a nextUri,
// which only leads to an empty FINISHED page.
queries.set(id, {
sql,
pages: [
{
id,
columns: [{ name: 'id', type: 'integer' }],
data: Array.from({ length: 10_000 }, (_, i) => [i + 1]),
nextUri: nextUri(id, 2),
stats: { state: 'FINISHED' }
},
{ id, stats: { state: 'FINISHED' } }
]
});
return {
id,
columns: [{ name: 'id', type: 'integer' }],
data: [],
nextUri: nextUri(id, 1),
stats: { state: 'RUNNING' }
};
}
if (sql.includes('FROM finished_paged_table')) {
queries.set(id, { sql, pages: [{ id, data: rows(11, 10), stats: { state: 'FINISHED' } }] });
return {
Expand Down
59 changes: 59 additions & 0 deletions e2e/trino/trino.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,65 @@ test.describe('Trino query editor', () => {
await expect(table.getByRole('cell', { name: '1' })).toBeVisible();
await expect(table.getByRole('cell', { name: 'Alice' })).toBeVisible();
await expect(page.getByText('Rows 1–3 of 3')).toBeVisible();
await expect(page.getByText(/^3 rows returned \|/)).toBeVisible();
});

test('hitting the row limit is reported in the status bar', async ({ page }) => {
await setTabSql(page, 'SELECT * FROM huge_table');
await page.goto('/trino');
await waitForHydration(page);
await page.locator('.monaco-editor').first().click();

await page.getByRole('button', { name: 'Run', exact: true }).click();
await waitForQueryComplete(page);

const status = page.locator('[data-query-state="FINISHED"]');
await expect(status.getByText('Row limit reached', { exact: true })).toBeVisible();
await expect(status.getByText(/^10,001 rows returned \|/)).toBeVisible();
await expect(status).not.toContainText('rows processed');
await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible();
// A single statement's row limit is only reported in the status bar.
await expect(page.getByText(/remaining rows not fetched/)).toHaveCount(0);
});

test('a result of exactly the row limit is not reported as truncated', async ({ page }) => {
await setTabSql(page, 'SELECT * FROM exact_limit_table');
await page.goto('/trino');
await waitForHydration(page);
await page.locator('.monaco-editor').first().click();

await page.getByRole('button', { name: 'Run', exact: true }).click();
await waitForQueryComplete(page);

const status = page.locator('[data-query-state="FINISHED"]');
await expect(status.getByText('Finished', { exact: true })).toBeVisible();
await expect(status.getByText(/^10,000 rows returned \|/)).toBeVisible();
await expect(page.getByText('Row limit reached')).toHaveCount(0);
});

test('row limit in a multi-statement script is reported on the affected statement', async ({
page
}) => {
await setTabSql(page, 'SELECT * FROM huge_table; SELECT id, name FROM users');
await page.goto('/trino');
await waitForHydration(page);
await page.locator('.monaco-editor').first().click();

await page.keyboard.press('Control+Shift+Enter');
await expect(page.getByRole('table', { name: 'Query results — Statement 2' })).toBeVisible();

const panelFor = async (index: number) => {
const toggle = page.getByRole('button', { name: `Toggle statement ${index} results` });
return page.locator(`[id="${await toggle.getAttribute('aria-controls')}"]`);
};
const warning = 'Row limit of 10,000 exceeded — remaining rows not fetched.';
await expect((await panelFor(1)).getByText(warning)).toBeVisible();
await expect((await panelFor(2)).getByText(warning)).toHaveCount(0);

// The status bar describes the last statement, which completed normally.
const status = page.locator('[data-query-state="FINISHED"]');
await expect(status.getByText('Finished', { exact: true })).toBeVisible();
await expect(status.getByText(/^3 rows returned \|/)).toBeVisible();
});

test('Ctrl+Enter triggers query execution', async ({ page }) => {
Expand Down
7 changes: 5 additions & 2 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,11 @@
"trino_state_finished": "Abgeschlossen",
"trino_state_failed": "Fehlgeschlagen",
"trino_state_cancelled": "Abgebrochen",
"trino_progress_info": "{rows} Zeilen verarbeitet | {elapsed} s",
"trino_row_limit_reached": "Zeilenlimit von {limit} erreicht — Abfrage abgebrochen.",
"trino_state_row_limit": "Zeilenlimit erreicht",
"trino_progress_info": "{rows} Zeilen abgerufen | {elapsed}",
"trino_elapsed_ms": "{value} ms",
"trino_elapsed_s": "{value} s",
"trino_row_limit_reached": "Zeilenlimit von {limit} überschritten — weitere Zeilen wurden nicht abgerufen.",
"trino_catalog_browser": "Katalogbrowser",
"trino_catalog_browser_toggle": "Katalogbrowser ein-/ausblenden",
"trino_catalog_label": "Katalog",
Expand Down
7 changes: 5 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,11 @@
"trino_state_finished": "Finished",
"trino_state_failed": "Failed",
"trino_state_cancelled": "Cancelled",
"trino_progress_info": "{rows} rows processed | {elapsed}s",
"trino_row_limit_reached": "Row limit of {limit} reached — query cancelled.",
"trino_state_row_limit": "Row limit reached",
"trino_progress_info": "{rows} rows returned | {elapsed}",
"trino_elapsed_ms": "{value} ms",
"trino_elapsed_s": "{value} s",
"trino_row_limit_reached": "Row limit of {limit} exceeded — remaining rows not fetched.",
"trino_catalog_browser": "Catalog browser",
"trino_catalog_browser_toggle": "Toggle catalog browser",
"trino_catalog_label": "Catalog",
Expand Down
15 changes: 9 additions & 6 deletions src/lib/components/trino/StatementResult.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script lang="ts">
import { browser } from '$app/environment';
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import IconExpandMore from 'virtual:icons/material-symbols/expand-more';
import IconOpenInNew from 'virtual:icons/material-symbols/open-in-new';
import IconDownload from 'virtual:icons/material-symbols/download';
import type { QuerySnapshot } from '$lib/types/query';
import { MAX_CLIENT_ROWS, type QuerySnapshot } from '$lib/types/query';
import { initPageSize, type PageSize } from '$lib/types/pagination.js';
import Pagination from '$lib/components/Pagination.svelte';

Expand Down Expand Up @@ -35,10 +36,12 @@
const rowStart = $derived(currentPage * pageSize + 1);
const rowEnd = $derived(currentPage * pageSize + displayedRows.length);
const stmtError = $derived(result.error);
const showHeader = $derived(totalStatements > 1);
const rowLimitWarning = $derived(
result.rowLimit !== null ? m.trino_row_limit_reached({ limit: result.rowLimit }) : null
showHeader && result.rowLimitReached
? m.trino_row_limit_reached({ limit: MAX_CLIENT_ROWS.toLocaleString(getLocale()) })
: null
);
const showHeader = $derived(totalStatements > 1);

const CSV_HEADERS_KEY = 'trino_csv_include_headers';
let includeHeaders = $state(browser ? localStorage.getItem(CSV_HEADERS_KEY) === 'true' : false);
Expand Down Expand Up @@ -203,6 +206,9 @@
">{stmtError}</pre>
</div>
{:else if result.state === 'FINISHED' && result.columns.length > 0}
{#if rowLimitWarning}
<p class="text-warning text-xs">{rowLimitWarning}</p>
{/if}
<div class="overflow-x-auto">
<table
class="table-zebra table-sm table"
Expand Down Expand Up @@ -234,9 +240,6 @@
</tbody>
</table>
</div>
{#if rowLimitWarning}
<span class="text-warning mt-1 block text-xs">{rowLimitWarning}</span>
{/if}
<div class="pt-2">
<Pagination
bind:pageSize
Expand Down
10 changes: 4 additions & 6 deletions src/lib/server/trino/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,14 @@ describe('toQueryProgress', () => {
it('returns zeroed progress without stats', () => {
expect(q.toQueryProgress(undefined)).toEqual({
progressPercentage: 0,
processedRows: 0,
elapsedTimeMillis: 0
});
});

it('defaults missing stat fields to 0', () => {
expect(q.toQueryProgress({ state: 'RUNNING', processedRows: 42 })).toEqual({
expect(q.toQueryProgress({ state: 'RUNNING', elapsedTimeMillis: 42 })).toEqual({
progressPercentage: 0,
processedRows: 42,
elapsedTimeMillis: 0
elapsedTimeMillis: 42
});
});
});
Expand Down Expand Up @@ -217,7 +215,7 @@ describe('startScript', () => {
id: 'q1',
columns: [{ name: 'n', type: 'bigint' }],
data: [[7]],
stats: { state: 'FINISHED', processedRows: 1 }
stats: { state: 'FINISHED', elapsedTimeMillis: 1 }
});

await q.startScript(trinoClient(), USER, TAB, ['SELECT 7'], OPTS);
Expand All @@ -229,7 +227,7 @@ describe('startScript', () => {
state: 'FINISHED',
columns: [{ name: 'n', type: 'bigint' }],
rows: [[7]],
progress: { processedRows: 1 }
progress: { elapsedTimeMillis: 1 }
});
});

Expand Down
8 changes: 4 additions & 4 deletions src/lib/server/trino/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface TrinoQuery {
columns: Column[];
rows: unknown[][];
error: string | null;
rowLimit: number | null;
rowLimitReached: boolean;
sql: string;
startedAt: number;
nextUri: string | undefined;
Expand All @@ -52,7 +52,6 @@ export function toQueryProgress(stats: TrinoQueryStats | undefined): QueryProgre
if (!stats) return INITIAL_PROGRESS;
return {
progressPercentage: stats.progressPercentage ?? 0,
processedRows: stats.processedRows ?? 0,
elapsedTimeMillis: stats.elapsedTimeMillis ?? 0
};
}
Expand Down Expand Up @@ -154,8 +153,9 @@ function buildSnapshot(
progress: query.progress,
columns: lightweight ? [] : query.columns,
rows: lightweight ? [] : query.rows,
rowCount: query.rows.length,
error: query.error,
rowLimit: query.rowLimit,
rowLimitReached: query.rowLimitReached,
sql: query.sql,
startedAt: query.startedAt
};
Expand Down Expand Up @@ -207,7 +207,7 @@ async function submitStatement(
columns: submitResult.columns ?? [],
rows: submitResult.data ?? [],
error: null,
rowLimit: null,
rowLimitReached: false,
sql,
startedAt: Date.now(),
nextUri: submitResult.nextUri,
Expand Down
80 changes: 76 additions & 4 deletions src/lib/server/trino/result-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function makeQuery(client: Partial<TrinoQuery['client']>): TrinoQuery {
columns: [],
rows: [],
error: null,
rowLimit: null,
rowLimitReached: false,
sql: 'SELECT * FROM big',
startedAt: 0,
nextUri: 'http://trino/next-1',
Expand Down Expand Up @@ -56,10 +56,11 @@ describe('collectResults row limit', () => {

expect(cancelViaUri).toHaveBeenCalledWith('http://trino/next-3', 'alice');
expect(cancel).not.toHaveBeenCalled();
expect(query.rowLimit).toBe(MAX_CLIENT_ROWS);
expect(query.rowLimitReached).toBe(true);
expect(query.error).toBeNull();
expect(query.state).toBe('FINISHED');
expect(query.rows.length).toBeGreaterThanOrEqual(MAX_CLIENT_ROWS);
// Rows from the crossing page are kept, not truncated.
expect(query.rows.length).toBe(2 * page().length);
});

it('falls back to cancel(queryId) when the crossing page has no nextUri', async () => {
Expand All @@ -77,10 +78,81 @@ describe('collectResults row limit', () => {

expect(cancelViaUri).not.toHaveBeenCalled();
expect(cancel).toHaveBeenCalledWith('q1', 'alice');
expect(query.rowLimit).toBe(MAX_CLIENT_ROWS);
expect(query.rows.length).toBe(MAX_CLIENT_ROWS + 1);
expect(query.rowLimitReached).toBe(true);
expect(query.error).toBeNull();
expect(query.state).toBe('FINISHED');
});

it('does not flag a result of exactly MAX_CLIENT_ROWS without further pages', async () => {
const cancelViaUri = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
const poll = vi.fn(async () => ({
id: 'q1',
data: Array.from({ length: MAX_CLIENT_ROWS }, () => [1]),
nextUri: undefined,
stats: { state: 'FINISHED' }
}));

const query = makeQuery({ poll, cancelViaUri, cancel });
await collectResults(query);

expect(cancelViaUri).not.toHaveBeenCalled();
expect(cancel).not.toHaveBeenCalled();
expect(query.rowLimitReached).toBe(false);
expect(query.rows.length).toBe(MAX_CLIENT_ROWS);
expect(query.state).toBe('FINISHED');
});

it('does not flag exactly MAX_CLIENT_ROWS followed by an empty trailing page', async () => {
// Trino sends a nextUri on the page completing e.g. `LIMIT 10000`, even when FINISHED.
const cancelViaUri = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
const poll = vi
.fn()
.mockResolvedValueOnce({
id: 'q1',
data: Array.from({ length: MAX_CLIENT_ROWS }, () => [1]),
nextUri: 'http://trino/next-2',
stats: { state: 'FINISHED' }
})
.mockResolvedValueOnce({ id: 'q1', stats: { state: 'FINISHED' } });

const query = makeQuery({ poll, cancelViaUri, cancel });
await collectResults(query);

expect(poll).toHaveBeenCalledTimes(2);
expect(cancelViaUri).not.toHaveBeenCalled();
expect(cancel).not.toHaveBeenCalled();
expect(query.rowLimitReached).toBe(false);
expect(query.rows.length).toBe(MAX_CLIENT_ROWS);
expect(query.state).toBe('FINISHED');
});

it('flags exactly MAX_CLIENT_ROWS once the next page delivers more rows', async () => {
const cancelViaUri = vi.fn(async () => {});
const poll = vi
.fn()
.mockResolvedValueOnce({
id: 'q1',
data: Array.from({ length: MAX_CLIENT_ROWS }, () => [1]),
nextUri: 'http://trino/next-2',
stats: { state: 'RUNNING' }
})
.mockResolvedValueOnce({
id: 'q1',
data: [[1], [1]],
nextUri: 'http://trino/next-3',
stats: { state: 'RUNNING' }
});

const query = makeQuery({ poll, cancelViaUri, cancel: vi.fn(async () => {}) });
await collectResults(query);

expect(cancelViaUri).toHaveBeenCalledWith('http://trino/next-3', 'alice');
expect(query.rowLimitReached).toBe(true);
expect(query.rows.length).toBe(MAX_CLIENT_ROWS + 2);
});
});

describe('collectResults failure', () => {
Expand Down
Loading
Loading