diff --git a/apps/web/src/backend/api/sql-page-wrap.test.ts b/apps/web/src/backend/api/sql-page-wrap.test.ts index 81cf9ce..852d179 100644 --- a/apps/web/src/backend/api/sql-page-wrap.test.ts +++ b/apps/web/src/backend/api/sql-page-wrap.test.ts @@ -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', () => { @@ -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'], diff --git a/apps/web/src/backend/api/sql-page-wrap.ts b/apps/web/src/backend/api/sql-page-wrap.ts index 95c34f0..3e8123a 100644 --- a/apps/web/src/backend/api/sql-page-wrap.ts +++ b/apps/web/src/backend/api/sql-page-wrap.ts @@ -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; @@ -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. @@ -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`;