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
39 changes: 37 additions & 2 deletions apps/web/src/backend/api/sql-page-wrap.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { isPageableStatement, trimPageProbe, wrapSqlForPage } from './sql-page-wrap';
import {
hasTopLevelOrderBy,
isPageableStatement,
trimPageProbe,
wrapSqlForPage,
} from './sql-page-wrap';

describe('sql-page-wrap', () => {
it('wraps postgres-style with LIMIT/OFFSET and +1 probe', () => {
Expand All @@ -8,12 +13,42 @@ describe('sql-page-wrap', () => {
);
});

it('wraps sqlserver with ORDER BY OFFSET FETCH', () => {
it('wraps sqlserver without ORDER BY using dummy ORDER BY + OFFSET FETCH', () => {
const sql = wrapSqlForPage('SELECT id FROM t', 'sqlserver', 0, 10);
expect(sql).toContain('OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY');
expect(sql).toContain('ORDER BY (SELECT NULL)');
});

it('uses T-SQL OFFSET/FETCH for azuresql (not MySQL LIMIT)', () => {
const sql = wrapSqlForPage('SELECT id FROM t', 'azuresql', 20, 10);
expect(sql).toContain('OFFSET 20 ROWS FETCH NEXT 11 ROWS ONLY');
expect(sql).not.toMatch(/\bLIMIT\b/i);
});

it('appends OFFSET/FETCH when T-SQL query already has top-level ORDER BY', () => {
const sql = wrapSqlForPage('SELECT id FROM t ORDER BY id', 'sqlserver', 10, 5);
expect(sql).toBe('SELECT id FROM t ORDER BY id OFFSET 10 ROWS FETCH NEXT 6 ROWS ONLY');
});

it('appends OFFSET/FETCH for azuresql ORDER BY queries', () => {
const sql = wrapSqlForPage(
'SELECT id, name FROM dbo.users ORDER BY name;',
'azuresql',
0,
50
);
expect(sql).toBe(
'SELECT id, name FROM dbo.users ORDER BY name OFFSET 0 ROWS FETCH NEXT 51 ROWS ONLY'
);
});

it('hasTopLevelOrderBy ignores ORDER BY inside subqueries/strings', () => {
expect(hasTopLevelOrderBy('SELECT id FROM t ORDER BY id')).toBe(true);
expect(hasTopLevelOrderBy('SELECT * FROM (SELECT id FROM t ORDER BY id) x')).toBe(false);
expect(hasTopLevelOrderBy("SELECT 'ORDER BY' AS s FROM t")).toBe(false);
expect(hasTopLevelOrderBy('SELECT id FROM t -- ORDER BY id')).toBe(false);
});

it('trimPageProbe drops probe row and sets hasNext', () => {
const shaped = {
columns: ['a'],
Expand Down
100 changes: 97 additions & 3 deletions apps/web/src/backend/api/sql-page-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { statementVerb } from '@foxschema/core';
/** Verbs that can appear as a subquery in `SELECT * FROM (…)` for paging. */
const PAGEABLE_VERBS = new Set(['select', 'values']);

/** Dialects that use T-SQL OFFSET/FETCH (not MySQL/Postgres LIMIT). */
const TSQL_DIALECTS = new Set(['sqlserver', 'mssql', 'azuresql']);

export function clampOffset(v: unknown): number {
const n = typeof v === 'number' ? Math.floor(v) : Number.NaN;
if (!Number.isFinite(n) || n < 0) return 0;
Expand All @@ -24,6 +27,91 @@ export function isPageableStatement(sql: string): boolean {
return verb !== null && PAGEABLE_VERBS.has(verb);
}

/**
* True when `sql` has a top-level `ORDER BY` (paren depth 0), ignoring
* strings and comments. Used so T-SQL paging can append OFFSET/FETCH to the
* original statement instead of nesting it in a derived table (SQL Server
* rejects `ORDER BY` in a subquery without TOP/OFFSET).
*/
export function hasTopLevelOrderBy(sql: string): boolean {
let depth = 0;
let i = 0;
const n = sql.length;
while (i < n) {
const ch = sql[i]!;
const next = sql[i + 1] ?? '';

if (ch === '-' && next === '-') {
i += 2;
while (i < n && sql[i] !== '\n') i++;
continue;
}
if (ch === '/' && next === '*') {
i += 2;
while (i < n - 1 && !(sql[i] === '*' && sql[i + 1] === '/')) i++;
i = Math.min(n, i + 2);
continue;
}
if (ch === "'") {
i++;
while (i < n) {
if (sql[i] === "'" && sql[i + 1] === "'") {
i += 2;
continue;
}
if (sql[i] === "'") {
i++;
break;
}
i++;
}
continue;
}
if (ch === '"') {
i++;
while (i < n) {
if (sql[i] === '"' && sql[i + 1] === '"') {
i += 2;
continue;
}
if (sql[i] === '"') {
i++;
break;
}
i++;
}
continue;
}
if (ch === '[') {
i++;
while (i < n && sql[i] !== ']') i++;
i = Math.min(n, i + 1);
continue;
}

if (ch === '(') {
depth++;
i++;
continue;
}
if (ch === ')') {
depth = Math.max(0, depth - 1);
i++;
continue;
}

if (depth === 0 && (ch === 'o' || ch === 'O')) {
// Word-boundary ORDER BY at top level.
if (/\border\s+by\b/i.test(sql.slice(i, i + 16))) {
const before = i === 0 ? ' ' : sql[i - 1]!;
if (!/[A-Za-z0-9_]/.test(before)) return true;
}
}
i++;
}
return false;
}

/**
* Best-effort page wrap. Dialects without OFFSET still get a subquery + LIMIT
* when offset is 0; non-zero offset uses the closest dialect syntax.
Expand All @@ -39,9 +127,15 @@ export function wrapSqlForPage(
const inner = trimmed;
const fetchLimit = limit + 1; // +1 probe row

if (d === 'sqlserver' || d === 'mssql') {
// SQL Server requires ORDER BY for OFFSET/FETCH.
return `SELECT * FROM (${inner}) AS _fox_page ORDER BY (SELECT NULL) OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`;
if (TSQL_DIALECTS.has(d)) {
// SQL Server / Azure SQL require ORDER BY for OFFSET/FETCH.
// Prefer appending to a top-level ORDER BY so paging stays stable and the
// engine accepts the statement (ORDER BY inside a bare derived table is illegal).
const fetch = `OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`;
if (hasTopLevelOrderBy(inner)) {
return `${inner} ${fetch}`;
}
return `SELECT * FROM (${inner}) AS _fox_page ORDER BY (SELECT NULL) ${fetch}`;
}
if (d === 'oracle') {
return `SELECT * FROM (${inner}) _fox_page OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`;
Expand Down
Loading