Files
supabase/apps/studio/data/sql/__tests__/utils.test.ts
T
Anthony LioandJoshen Lim c3fd259904 fix(ai): assistant sql row cap (#51608)
## Problem

attempt to [fix this
ticket](https://supabase.frontapp.com/inboxes/teams/folders/27650590/unassigned/116845868830):
when the studio ai assistant runs a query with previously set database
opt-in, `execute_sql` returns every row while the query cell's footer
says "Limit 100 rows" causing in case of large amount of returned rows
browser to freeze

+ the chat fails with "Sorry, I'm having trouble responding right now"
until the user clears it

## Solution

- `execute_sql` now keeps the first 100 rows using the cell's existing
`DEFAULT_CELL_ROW_LIMIT`
- uses in history sanitizer the same `toModelRows` helper
- chats saved before this change have no `totalRows` so the helper falls
back to `rows.length` and cuts them to 100 too

<!--
## Preview links

If relevant, include links to changed pages for easy review access.

Copy the preview base URL from the Vercel bot comment on this PR. Use
the following table as an example template.

| Site | Live | Preview | Search for |
| -------------- |
-------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------------------------
| ----------------------------- |
| WWW | [/blog/your-post](https://supabase.com/blog/your-post) |
[/blog/your-post](https://zone-www-dot-com-git-branch-name-supabase.vercel.app/blog/your-post)
| unique phrase from the change |
| Docs |
[/docs/guides/your-page](https://supabase.com/docs/guides/your-page) |
[/docs/guides/your-page](https://docs-git-branch-name-supabase.vercel.app/docs/guides/your-page)
| unique phrase from the change |
| Studio | [/dashboard](https://supabase.com/dashboard) |
[/dashboard](https://studio-git-branch-name-supabase.vercel.app/dashboard)
| unique phrase from the change |
| Design system | [/design-system](https://supabase.com/design-system) |
[/design-system](https://design-system-git-branch-name-supabase.vercel.app/design-system)
| unique phrase from the change |
| UI library | [/library](https://supabase.com/library) |
[/library](https://ui-library-git-branch-name-supabase.vercel.app/library)
| unique phrase from the change |
| Knowledge base |
[/kb/guides/your-page](https://supabase.com/kb/guides/your-page) |
[/kb/guides/your-page](https://kb-git-branch-name-supabase.vercel.app/kb/guides/your-page)
| unique phrase from the change |
-->

<!-- ## Additional context

Optionally add any other context or screenshots.

-->

## Review instructions

1. In a test project, run this in the SQL editor:
   ```sql
create table "emailSubscriber" (id bigint generated always as identity
primary key, email text);
create table "stripeCustomer" (id bigint generated always as identity
primary key, email text);
insert into "emailSubscriber" (email) select md5(g::text) ||
'@example.com' from generate_series(1, 50000) g;
insert into "stripeCustomer" (email) select md5(g::text) ||
'@example.com' from generate_series(1, 1000) g;
2. on the live dashboard, open the assistant in a new chat and send "i
want all the emails in emailSubscriber where the email is not in the
stripeCustomer table"
3. in the opt in requested settings choose "Schema, Logs & Database
Data"
4. then click run query, the cell should show 100 rows (vs 50000 rows in
prod)

## Checklist

Check all before review:

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs
[style
guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide)

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-10-15 12:22:16 +03:00

248 lines
10 KiB
TypeScript

import { safeSql } from '@supabase/pg-meta'
import { describe, expect, it, test } from 'vitest'
import { applyAutoLimit, getSqlErrorLines, trimTrailingSemicolons } from '../utils'
describe('getSqlErrorLines', () => {
it('returns formattedError lines when present', () => {
const lines = getSqlErrorLines({
message: 'permission denied for table users',
formattedError:
'ERROR: 42501: permission denied for table users\n' +
'HINT: To grant access to anon on a specific table:\n' +
' GRANT SELECT ON TABLE public.users TO anon;',
})
expect(lines).toEqual([
'ERROR: 42501: permission denied for table users',
'HINT: To grant access to anon on a specific table:',
' GRANT SELECT ON TABLE public.users TO anon;',
])
})
it('strips empty lines from formattedError', () => {
const lines = getSqlErrorLines({
formattedError: 'ERROR: boom\n\nHINT: retry\n',
})
expect(lines).toEqual(['ERROR: boom', 'HINT: retry'])
})
it('falls back to message lines when formattedError is missing and message is multi-line', () => {
const lines = getSqlErrorLines({
message:
'ERROR: 42501: permission denied for table users\n' +
'HINT: To grant access to anon on a specific table:\n' +
' GRANT SELECT ON TABLE public.users TO anon;',
})
expect(lines).toEqual([
'ERROR: 42501: permission denied for table users',
'HINT: To grant access to anon on a specific table:',
' GRANT SELECT ON TABLE public.users TO anon;',
])
})
it('returns empty array for a single-line message so callers render the fallback', () => {
const lines = getSqlErrorLines({ message: 'permission denied for table users' })
expect(lines).toEqual([])
})
it('returns empty array when both fields are missing', () => {
expect(getSqlErrorLines({})).toEqual([])
})
it('returns empty array when message is an empty string', () => {
expect(getSqlErrorLines({ message: '' })).toEqual([])
})
it('returns empty array when message only contains whitespace newlines', () => {
// Only empty segments after filtering — treated as single-line
expect(getSqlErrorLines({ message: '\n\n' })).toEqual([])
})
it('prefers formattedError even when message is also multi-line', () => {
const lines = getSqlErrorLines({
message: 'message line 1\nmessage line 2',
formattedError: 'formatted line 1\nformatted line 2',
})
expect(lines).toEqual(['formatted line 1', 'formatted line 2'])
})
it('falls through to message when formattedError is empty string', () => {
const lines = getSqlErrorLines({
message: 'ERROR: line 1\nHINT: line 2',
formattedError: '',
})
expect(lines).toEqual(['ERROR: line 1', 'HINT: line 2'])
})
})
describe('trimTrailingSemicolons', () => {
test('removes a single trailing semicolon', () => {
const sql = safeSql`select * from countries;`
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
})
test('removes multiple trailing semicolons', () => {
const sql = safeSql`select * from countries;;;;;;;`
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
})
test('leaves a fragment with no trailing semicolon unchanged', () => {
const sql = safeSql`select * from countries`
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
})
test('does not touch semicolons that are not trailing', () => {
const sql = safeSql`select 1; select 2`
expect(trimTrailingSemicolons(sql)).toBe('select 1; select 2')
})
})
describe('applyAutoLimit', () => {
test('Should return false if limit passed is <= 0', () => {
const sql = safeSql`select * from countries;`
const limit = -1
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return true if limit passed is > 0', () => {
const sql = safeSql`select * from countries;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(true)
})
test('Should return false if query already has a limit', () => {
const sql = safeSql`select * from countries limit 10;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query already has a limit (check for case-insensitiveness)', () => {
const sql = safeSql`SELECT * FROM countries LIMIT 10;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query already has a limit with whitespace before the semi colon', () => {
const sql = safeSql`select * from countries limit 10 ;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query already has a limit and offset', () => {
const sql = safeSql`select * from countries limit 10 offset 0;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query already has a limit and offset with whitespace before the semi colon', () => {
const sql = safeSql`select * from countries limit 10 offset 0 ;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => {
const sql = safeSql`select * from countries offset 0 limit 1;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query already has a limit, even if no value provided for limit', () => {
const sql = safeSql`select * from countries limit`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query uses `FETCH FIRST` instead of limit ', () => {
const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query uses `fetch first` instead of limit ', () => {
const sql = safeSql`select * from countries fetch first 5 rows only`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query uses `fetch first` (with random spaces) instead of limit ', () => {
const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query uses `FETCH NEXT` instead of limit ', () => {
const sql = safeSql`select * from countries offset 5 rows FETCH NEXT 5 rows only`
const { sql: result, appendAutoLimit } = applyAutoLimit(sql, 100)
expect(appendAutoLimit).toBe(false)
expect(result).toBe(sql)
})
test('Should return false if query is not a select statement', () => {
const sql = safeSql`create table test (id int8 primary key, name varchar);`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if there are multiple queries I', () => {
const sql1 = safeSql`select * from countries;
select * from cities;`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql1, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if there are multiple queries II', () => {
const sql1 = safeSql`select * from countries;
select * from cities`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql1, limit)
expect(appendAutoLimit).toBe(false)
})
// [Joshen] Opting to just avoid appending in this case to prevent making the logic overly complex atm
test('Should return false if query has with a comment I', () => {
const sql = safeSql`-- This is a comment
select * from cities`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
test('Should return false if query has with a comment II', () => {
const sql = safeSql`select * from cities
-- This is a comment`
const limit = 100
const { appendAutoLimit } = applyAutoLimit(sql, limit)
expect(appendAutoLimit).toBe(false)
})
// [Joshen] These will just need to test the cases when appendAutoLimit returns true then
test('Should add the limit param properly if query ends without a semi colon', () => {
const sql = safeSql`select * from countries`
const limit = 100
const { sql: formattedSql } = applyAutoLimit(sql, limit)
expect(formattedSql).toBe('select * from countries limit 100;')
})
test('Should add the limit param properly if query ends with a semi colon', () => {
const sql = safeSql`select * from countries;`
const limit = 100
const { sql: formattedSql } = applyAutoLimit(sql, limit)
expect(formattedSql).toBe('select * from countries limit 100;')
})
test('Should add the limit param properly if query ends with multiple semi colon', () => {
const sql = safeSql`select * from countries;;;;;;;`
const limit = 100
const { sql: formattedSql } = applyAutoLimit(sql, limit)
expect(formattedSql).toBe('select * from countries limit 100;')
})
test('Should not append a limit if query already has one with whitespace before the semi colon', () => {
const sql = safeSql`select * from countries limit 10 ;`
const limit = 100
const { sql: formattedSql } = applyAutoLimit(sql, limit)
expect(formattedSql).toBe('select * from countries limit 10 ;')
})
test('returns the SafeSqlFragment result unchanged when no limit is appended', () => {
const sql = safeSql`select * from countries limit 10;`
const { sql: formattedSql } = applyAutoLimit(sql, 100)
expect(formattedSql).toBe(sql)
})
})