mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
558bee9ebf
## Context Currently with the SQL Editor, the auto-completion via intellisense only works nicely with the `.` operator - e.g after keying in a schema and trying to find a table as such: <img width="500" alt="image" src="https://github.com/user-attachments/assets/86ec8455-47b9-43d3-925a-c13d0fd6ac44" /> But lacks support for finding the columns of a table after a `where` clause - so the changes here addresses that by mainly adjust the `PgSQLCompletionProvider` Also addresses a number of type fixes (replaces all the `any` types) <img width="500" alt="image" src="https://github.com/user-attachments/assets/e23672a6-2b48-4a98-b300-69f822d12b38" /> <img width="415" height="319" alt="image" src="https://github.com/user-attachments/assets/e6ee64d2-90dd-47d6-9fd2-e8b07821ebb6" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved PostgreSQL SQL editor suggestions with table, column, alias, schema, and join-aware completions. * Added context-aware support for statements, quoted identifiers, subqueries, and qualified columns. * Enhanced PostgreSQL function signature assistance. * Added safer behavior when database metadata is incomplete or unavailable. * **Bug Fixes** * Prioritized relevant columns and removed duplicate suggestions. * **Tests** * Added comprehensive coverage for SQL parsing, metadata handling, and completion behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
258 lines
8.9 KiB
TypeScript
258 lines
8.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
filterTablesByReferences,
|
|
getFromClauseTables,
|
|
getStatementAtOffset,
|
|
parseTrailingDotIdent,
|
|
resolveTablesForIdent,
|
|
} from './PgSQLCompletionProvider.utils'
|
|
|
|
describe('getFromClauseTables', () => {
|
|
it('returns empty array when there is no FROM clause', () => {
|
|
expect(getFromClauseTables('select 1')).toStrictEqual([])
|
|
})
|
|
|
|
it('parses a single table', () => {
|
|
expect(getFromClauseTables('select * from colors where id = 1')).toStrictEqual([
|
|
{ name: 'colors', alias: undefined },
|
|
])
|
|
})
|
|
|
|
it('parses a table with an implicit alias (no AS)', () => {
|
|
expect(getFromClauseTables('select * from colors c where c.id = 1')).toStrictEqual([
|
|
{ name: 'colors', alias: 'c' },
|
|
])
|
|
})
|
|
|
|
it('parses a table with an explicit AS alias', () => {
|
|
expect(getFromClauseTables('select * from colors as c where c.id = 1')).toStrictEqual([
|
|
{ name: 'colors', alias: 'c' },
|
|
])
|
|
})
|
|
|
|
it('parses a schema-qualified table', () => {
|
|
expect(getFromClauseTables('select * from public.colors where id = 1')).toStrictEqual([
|
|
{ schema: 'public', name: 'colors', alias: undefined },
|
|
])
|
|
})
|
|
|
|
it('parses a schema-qualified table with an alias', () => {
|
|
expect(getFromClauseTables('select * from public.colors c where c.id = 1')).toStrictEqual([
|
|
{ schema: 'public', name: 'colors', alias: 'c' },
|
|
])
|
|
})
|
|
|
|
it('parses multiple comma-separated tables', () => {
|
|
expect(
|
|
getFromClauseTables('select * from colors, shapes where colors.id = shapes.id')
|
|
).toStrictEqual([
|
|
{ name: 'colors', alias: undefined },
|
|
{ name: 'shapes', alias: undefined },
|
|
])
|
|
})
|
|
|
|
it('parses quoted identifiers, unquoting the result', () => {
|
|
expect(getFromClauseTables('select * from "Colors" "C" where "C".id = 1')).toStrictEqual([
|
|
{ name: 'Colors', alias: 'C' },
|
|
])
|
|
})
|
|
|
|
it('parses tables across inner/left/right/full/cross joins', () => {
|
|
expect(
|
|
getFromClauseTables(
|
|
`select * from colors
|
|
inner join a on a.id = colors.id
|
|
left join b on b.id = colors.id
|
|
left outer join c on c.id = colors.id
|
|
right join d on d.id = colors.id
|
|
full outer join e on e.id = colors.id
|
|
cross join f`
|
|
)
|
|
).toStrictEqual([
|
|
{ name: 'colors', alias: undefined },
|
|
{ name: 'a', alias: undefined },
|
|
{ name: 'b', alias: undefined },
|
|
{ name: 'c', alias: undefined },
|
|
{ name: 'd', alias: undefined },
|
|
{ name: 'e', alias: undefined },
|
|
{ name: 'f', alias: undefined },
|
|
])
|
|
})
|
|
|
|
it('parses a plain JOIN (no join-type keyword)', () => {
|
|
expect(
|
|
getFromClauseTables('select * from colors join shapes on shapes.color_id = colors.id')
|
|
).toStrictEqual([
|
|
{ name: 'colors', alias: undefined },
|
|
{ name: 'shapes', alias: undefined },
|
|
])
|
|
})
|
|
|
|
it('is case-insensitive for FROM/JOIN keywords', () => {
|
|
expect(
|
|
getFromClauseTables('SELECT * FROM Colors C INNER JOIN Shapes S ON S.id = C.id')
|
|
).toStrictEqual([
|
|
{ name: 'Colors', alias: 'C' },
|
|
{ name: 'Shapes', alias: 'S' },
|
|
])
|
|
})
|
|
|
|
it('skips a subquery table source instead of mis-parsing it', () => {
|
|
expect(getFromClauseTables('select * from (select * from shapes) x')).toStrictEqual([])
|
|
})
|
|
|
|
it('still resolves real tables alongside a skipped subquery', () => {
|
|
expect(getFromClauseTables('select * from colors, (select * from shapes) x')).toStrictEqual([
|
|
{ name: 'colors', alias: undefined },
|
|
])
|
|
})
|
|
|
|
it('skips a function table source instead of mis-parsing it', () => {
|
|
expect(getFromClauseTables('select * from generate_series(1, 10) g')).toStrictEqual([])
|
|
})
|
|
|
|
it('stops the FROM clause at WHERE/GROUP BY/ORDER BY/LIMIT', () => {
|
|
expect(
|
|
getFromClauseTables('select * from colors group by id order by id limit 10')
|
|
).toStrictEqual([{ name: 'colors', alias: undefined }])
|
|
})
|
|
})
|
|
|
|
describe('getStatementAtOffset', () => {
|
|
it('returns the whole string when there is a single statement', () => {
|
|
const sql = 'select * from colors where id = 1'
|
|
expect(getStatementAtOffset(sql, 10)).toBe(sql)
|
|
})
|
|
|
|
it('returns only the statement containing the given offset', () => {
|
|
const sql = 'select * from colors; select * from shapes where id = 1;'
|
|
const offset = sql.indexOf('shapes')
|
|
expect(getStatementAtOffset(sql, offset).trim()).toBe('select * from shapes where id = 1')
|
|
})
|
|
|
|
it('returns the first statement when the offset is before any semicolon', () => {
|
|
const sql = 'select * from colors; select * from shapes;'
|
|
const offset = sql.indexOf('colors')
|
|
expect(getStatementAtOffset(sql, offset).trim()).toBe('select * from colors')
|
|
})
|
|
|
|
it('does not treat a semicolon inside a single-quoted string as a boundary', () => {
|
|
const sql = "select * from colors where name = 'a;b' and id = 1"
|
|
expect(getStatementAtOffset(sql, sql.length - 1)).toBe(sql)
|
|
})
|
|
|
|
it('does not treat a semicolon inside a double-quoted identifier as a boundary', () => {
|
|
const sql = 'select * from "weird;table" where id = 1'
|
|
expect(getStatementAtOffset(sql, sql.length - 1)).toBe(sql)
|
|
})
|
|
|
|
it('does not treat a semicolon inside a line comment as a boundary', () => {
|
|
const sql = 'select * from colors -- comment; still comment\nwhere id = 1'
|
|
expect(getStatementAtOffset(sql, sql.length - 1)).toBe(sql)
|
|
})
|
|
|
|
it('does not treat a semicolon inside a block comment as a boundary', () => {
|
|
const sql = 'select * from colors /* comment; still comment */ where id = 1'
|
|
expect(getStatementAtOffset(sql, sql.length - 1)).toBe(sql)
|
|
})
|
|
})
|
|
|
|
describe('filterTablesByReferences', () => {
|
|
const tables = [
|
|
{ schemaname: 'public', tablename: 'colors' },
|
|
{ schemaname: 'public', tablename: 'shapes' },
|
|
{ schemaname: 'internal', tablename: 'colors' },
|
|
]
|
|
|
|
it('returns an empty array when there are no references', () => {
|
|
expect(filterTablesByReferences(tables, [])).toStrictEqual([])
|
|
})
|
|
|
|
it('matches by table name only when no schema is specified', () => {
|
|
expect(filterTablesByReferences(tables, [{ name: 'colors' }])).toStrictEqual([
|
|
{ schemaname: 'public', tablename: 'colors' },
|
|
{ schemaname: 'internal', tablename: 'colors' },
|
|
])
|
|
})
|
|
|
|
it('matches by schema and table name when a schema is specified', () => {
|
|
expect(
|
|
filterTablesByReferences(tables, [{ schema: 'internal', name: 'colors' }])
|
|
).toStrictEqual([{ schemaname: 'internal', tablename: 'colors' }])
|
|
})
|
|
|
|
it('is case-insensitive when matching names', () => {
|
|
expect(filterTablesByReferences(tables, [{ schema: 'PUBLIC', name: 'COLORS' }])).toStrictEqual([
|
|
{ schemaname: 'public', tablename: 'colors' },
|
|
])
|
|
})
|
|
|
|
it('returns an empty array when no table matches', () => {
|
|
expect(filterTablesByReferences(tables, [{ name: 'nonexistent' }])).toStrictEqual([])
|
|
})
|
|
})
|
|
|
|
describe('parseTrailingDotIdent', () => {
|
|
it('returns null when the text does not end with an identifier and a dot', () => {
|
|
expect(parseTrailingDotIdent('select * from colors where ')).toBeNull()
|
|
})
|
|
|
|
it('extracts an unquoted identifier immediately before a trailing dot', () => {
|
|
expect(parseTrailingDotIdent('select * from colors c where c.')).toStrictEqual({
|
|
isQuoted: false,
|
|
name: 'c',
|
|
})
|
|
})
|
|
|
|
it('extracts and unquotes a quoted identifier before a trailing dot', () => {
|
|
expect(parseTrailingDotIdent('select * from "Colors" "C" where "C".')).toStrictEqual({
|
|
isQuoted: true,
|
|
name: 'C',
|
|
})
|
|
})
|
|
|
|
it('allows whitespace between the identifier and the dot', () => {
|
|
expect(parseTrailingDotIdent('select * from colors c where c .')).toStrictEqual({
|
|
isQuoted: false,
|
|
name: 'c',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('resolveTablesForIdent', () => {
|
|
const tables = [
|
|
{ schemaname: 'public', tablename: 'orders' },
|
|
{ schemaname: 'public', tablename: 'customers' },
|
|
]
|
|
|
|
it('resolves an alias to its table, ignoring other referenced tables', () => {
|
|
const refs = [
|
|
{ name: 'orders', alias: 'o' },
|
|
{ name: 'customers', alias: 'c' },
|
|
]
|
|
expect(resolveTablesForIdent(tables, refs, { isQuoted: false, name: 'c' })).toStrictEqual([
|
|
{ schemaname: 'public', tablename: 'customers' },
|
|
])
|
|
})
|
|
|
|
it('resolves a plain table name when there is no alias', () => {
|
|
const refs = [{ name: 'customers', alias: undefined }]
|
|
expect(
|
|
resolveTablesForIdent(tables, refs, { isQuoted: false, name: 'customers' })
|
|
).toStrictEqual([{ schemaname: 'public', tablename: 'customers' }])
|
|
})
|
|
|
|
it('does not fall back to the table name once it has an alias', () => {
|
|
const refs = [{ name: 'customers', alias: 'c' }]
|
|
expect(
|
|
resolveTablesForIdent(tables, refs, { isQuoted: false, name: 'customers' })
|
|
).toStrictEqual([])
|
|
})
|
|
|
|
it('returns an empty array when the ident matches nothing', () => {
|
|
const refs = [{ name: 'customers', alias: 'c' }]
|
|
expect(resolveTablesForIdent(tables, refs, { isQuoted: false, name: 'x' })).toStrictEqual([])
|
|
})
|
|
})
|