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 -->
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import type { Monaco } from '@monaco-editor/react'
|
|
import type { editor, languages } from 'monaco-editor'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import { getPgsqlCompletionProvider } from './PgSQLCompletionProvider'
|
|
import type { PgInfo } from './Providers.types'
|
|
|
|
// A minimal stand-in for the parts of `Monaco` this provider actually reads.
|
|
const monaco = {
|
|
languages: {
|
|
CompletionItemKind: {
|
|
Keyword: 1,
|
|
Class: 2,
|
|
Interface: 3,
|
|
Field: 4,
|
|
Function: 5,
|
|
Property: 6,
|
|
},
|
|
},
|
|
} as unknown as Monaco
|
|
|
|
function createModel(sql: string): editor.ITextModel {
|
|
return {
|
|
getValue: () => sql,
|
|
getOffsetAt: () => sql.length,
|
|
getLineContent: () => sql,
|
|
getWordUntilPosition: () => ({
|
|
word: '',
|
|
startColumn: sql.length + 1,
|
|
endColumn: sql.length + 1,
|
|
}),
|
|
} as unknown as editor.ITextModel
|
|
}
|
|
|
|
// Simulates the cursor sitting mid-identifier between an already-present pair of double quotes,
|
|
// e.g. `where "OrderD|"` (the closing quote auto-closed by the editor when `"` was typed).
|
|
function createQuotedIdentModel(line: string, word: string): editor.ITextModel {
|
|
const wordStartColumn = line.indexOf(`"${word}`) + 2
|
|
const wordEndColumn = wordStartColumn + word.length
|
|
|
|
return {
|
|
getValue: () => line,
|
|
getOffsetAt: () => wordEndColumn - 1,
|
|
getLineContent: () => line,
|
|
getWordUntilPosition: () => ({
|
|
word,
|
|
startColumn: wordStartColumn,
|
|
endColumn: wordEndColumn,
|
|
}),
|
|
} as unknown as editor.ITextModel
|
|
}
|
|
|
|
function createQuotedIdentPosition(line: string, word: string): ProvideCompletionItemsParams[1] {
|
|
const wordEndColumn = line.indexOf(`"${word}`) + 2 + word.length
|
|
return { column: wordEndColumn, lineNumber: 1 } as unknown as ProvideCompletionItemsParams[1]
|
|
}
|
|
|
|
type ProvideCompletionItemsParams = Parameters<
|
|
languages.CompletionItemProvider['provideCompletionItems']
|
|
>
|
|
|
|
function createPosition(sql: string): ProvideCompletionItemsParams[1] {
|
|
// Monaco columns are 1-indexed; column = length + 1 places the cursor at the end.
|
|
return { column: sql.length + 1, lineNumber: 1 } as unknown as ProvideCompletionItemsParams[1]
|
|
}
|
|
|
|
// Minimal fixtures — only the fields this provider reads, cast to the real (much larger) zod types.
|
|
function createPgInfoRef(): { current: PgInfo } {
|
|
return {
|
|
current: {
|
|
keywords: ['abort', 'absent', 'absolute'],
|
|
schemas: [{ name: 'public' }] as unknown as PgInfo['schemas'],
|
|
functions: [
|
|
{ name: '_crypto_aead_det_decrypt', return_type: 'bytea' },
|
|
] as unknown as PgInfo['functions'],
|
|
tableColumns: [
|
|
{
|
|
schemaname: 'public',
|
|
tablename: 'colors',
|
|
quoted_name: 'colors',
|
|
is_table: true,
|
|
columns: [
|
|
{ attname: 'id', data_type: 'bigint' },
|
|
{ attname: 'hex', data_type: 'text' },
|
|
],
|
|
},
|
|
{
|
|
schemaname: 'public',
|
|
tablename: 'shapes',
|
|
quoted_name: 'shapes',
|
|
is_table: true,
|
|
columns: [{ attname: 'sides', data_type: 'smallint' }],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
function getSuggestions(pgInfoRef: { current: PgInfo }, sql: string): languages.CompletionItem[] {
|
|
return provideSuggestions(pgInfoRef, sql, ' ')
|
|
}
|
|
|
|
function getDotSuggestions(
|
|
pgInfoRef: { current: PgInfo },
|
|
sql: string
|
|
): languages.CompletionItem[] {
|
|
return provideSuggestions(pgInfoRef, sql, '.')
|
|
}
|
|
|
|
function provideSuggestions(
|
|
pgInfoRef: { current: PgInfo },
|
|
sql: string,
|
|
triggerCharacter: string
|
|
): languages.CompletionItem[] {
|
|
const provider = getPgsqlCompletionProvider(monaco, pgInfoRef)
|
|
const context = { triggerCharacter } as unknown as ProvideCompletionItemsParams[2]
|
|
const token = {} as ProvideCompletionItemsParams[3]
|
|
|
|
const result = provider.provideCompletionItems(
|
|
createModel(sql),
|
|
createPosition(sql),
|
|
context,
|
|
token
|
|
) as languages.CompletionList
|
|
|
|
return result.suggestions
|
|
}
|
|
|
|
describe('getPgsqlCompletionProvider - default scenario', () => {
|
|
it('suggests only the FROM-clause table columns, ranked above keywords/functions', () => {
|
|
const pgInfoRef = createPgInfoRef()
|
|
const suggestions = getSuggestions(pgInfoRef, 'select * from colors where ')
|
|
|
|
const columnSuggestions = suggestions.filter(
|
|
(s) => s.kind === monaco.languages.CompletionItemKind.Field
|
|
)
|
|
expect(columnSuggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id'])
|
|
|
|
// Every in-scope column must be ranked (sortText) ahead of keywords/functions
|
|
columnSuggestions.forEach((s) => expect(s.sortText).toMatch(/^0_/))
|
|
const nonColumnSuggestions = suggestions.filter(
|
|
(s) => s.kind !== monaco.languages.CompletionItemKind.Field
|
|
)
|
|
nonColumnSuggestions.forEach((s) => expect(s.sortText).toBeUndefined())
|
|
})
|
|
|
|
it('falls back to every table column when no FROM clause is resolved yet', () => {
|
|
const pgInfoRef = createPgInfoRef()
|
|
const suggestions = getSuggestions(pgInfoRef, 'select ')
|
|
|
|
const columnSuggestions = suggestions.filter(
|
|
(s) => s.kind === monaco.languages.CompletionItemKind.Field
|
|
)
|
|
expect(columnSuggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id', 'sides'])
|
|
columnSuggestions.forEach((s) => expect(s.sortText).toBeUndefined())
|
|
})
|
|
|
|
it('narrows columns per-table across a join', () => {
|
|
const pgInfoRef = createPgInfoRef()
|
|
const suggestions = getSuggestions(
|
|
pgInfoRef,
|
|
'select * from colors c join shapes s on s.id = c.id where '
|
|
)
|
|
|
|
const columnSuggestions = suggestions.filter(
|
|
(s) => s.kind === monaco.languages.CompletionItemKind.Field
|
|
)
|
|
expect(columnSuggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id', 'sides'])
|
|
})
|
|
|
|
it('scopes to a single alias when the statement ends with `alias.`', () => {
|
|
const pgInfoRef = createPgInfoRef()
|
|
const suggestions = getSuggestions(
|
|
pgInfoRef,
|
|
'select * from colors c join shapes s on s.id = c.id where c.'
|
|
)
|
|
|
|
const columnSuggestions = suggestions.filter(
|
|
(s) => s.kind === monaco.languages.CompletionItemKind.Field
|
|
)
|
|
expect(columnSuggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id'])
|
|
})
|
|
})
|
|
|
|
describe('getPgsqlCompletionProvider - dot scenario', () => {
|
|
it('scopes to a table alias in a single-table query', () => {
|
|
const pgInfoRef = createPgInfoRef()
|
|
const suggestions = getDotSuggestions(pgInfoRef, 'select * from colors c where c.')
|
|
expect(suggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id'])
|
|
})
|
|
|
|
it('scopes to the aliased table only, not every table joined in', () => {
|
|
const pgInfoRef = createPgInfoRef()
|
|
const suggestions = getDotSuggestions(
|
|
pgInfoRef,
|
|
'select * from colors c join shapes s on s.id = c.id where c.'
|
|
)
|
|
expect(suggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id'])
|
|
})
|
|
|
|
it('still resolves a plain (un-aliased) table name', () => {
|
|
const pgInfoRef = createPgInfoRef()
|
|
const suggestions = getDotSuggestions(
|
|
pgInfoRef,
|
|
'select * from colors join shapes s on s.id = colors.id where colors.'
|
|
)
|
|
expect(suggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id'])
|
|
})
|
|
|
|
it('resolves an alias when there is no `public` schema at all', () => {
|
|
const pgInfoRef: { current: PgInfo } = {
|
|
current: {
|
|
keywords: [],
|
|
schemas: [{ name: 'app' }] as unknown as PgInfo['schemas'],
|
|
functions: [] as unknown as PgInfo['functions'],
|
|
tableColumns: [
|
|
{
|
|
schemaname: 'app',
|
|
tablename: 'colors',
|
|
quoted_name: 'colors',
|
|
is_table: true,
|
|
columns: [
|
|
{ attname: 'id', data_type: 'bigint' },
|
|
{ attname: 'hex', data_type: 'text' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
const suggestions = getDotSuggestions(pgInfoRef, 'select * from app.colors c where c.')
|
|
expect(suggestions.map((s) => s.label).sort()).toStrictEqual(['hex', 'id'])
|
|
})
|
|
})
|
|
|
|
describe('getPgsqlCompletionProvider - quoted identifiers', () => {
|
|
function createQuotedPgInfoRef(): { current: PgInfo } {
|
|
return {
|
|
current: {
|
|
keywords: [],
|
|
schemas: [{ name: 'public' }] as unknown as PgInfo['schemas'],
|
|
functions: [] as unknown as PgInfo['functions'],
|
|
tableColumns: [
|
|
{
|
|
schemaname: 'public',
|
|
tablename: 'test_orders',
|
|
quoted_name: 'test_orders',
|
|
is_table: true,
|
|
columns: [
|
|
{ attname: 'OrderDate', data_type: 'date' },
|
|
{ attname: 'id', data_type: 'bigint' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
it('replaces the whole existing quote pair instead of doubling up quotes on a mixed-case column', () => {
|
|
// Regression test for: typing `where "OrderD` with the editor auto-closing the quote to
|
|
// `where "OrderD|"`, then accepting the `OrderDate` suggestion produced `""OrderDate""`.
|
|
const pgInfoRef = createQuotedPgInfoRef()
|
|
const line = 'select * from test_orders where "OrderDate"'
|
|
const word = 'OrderDate'
|
|
|
|
const provider = getPgsqlCompletionProvider(monaco, pgInfoRef)
|
|
const context = { triggerCharacter: undefined } as unknown as ProvideCompletionItemsParams[2]
|
|
const token = {} as ProvideCompletionItemsParams[3]
|
|
|
|
const result = provider.provideCompletionItems(
|
|
createQuotedIdentModel(line, word),
|
|
createQuotedIdentPosition(line, word),
|
|
context,
|
|
token
|
|
) as languages.CompletionList
|
|
|
|
const suggestion = result.suggestions.find((s) => s.label === 'OrderDate')
|
|
expect(suggestion?.insertText).toBe('"OrderDate"')
|
|
|
|
// The range must swallow both surrounding quotes, otherwise the quoted insertText lands
|
|
// inside the pre-existing, untouched quote pair and doubles them up.
|
|
const quoteStart = line.indexOf('"')
|
|
const quoteEnd = line.indexOf('"', quoteStart + 1)
|
|
const range = suggestion?.range as { startColumn: number; endColumn: number }
|
|
expect(range.startColumn).toBe(quoteStart + 1)
|
|
expect(range.endColumn).toBe(quoteEnd + 2)
|
|
})
|
|
|
|
it('sets filterText with the leading quote so Monaco does not filter out every suggestion while typing inside an auto-closed quote pair', () => {
|
|
// Regression test for: with the range widened to swallow the opening quote (above), Monaco
|
|
// fuzzy-matches the typed prefix (e.g. `"OrderD`) against each item's filterText/label. A plain
|
|
// label like `OrderDate` has no leading quote to match, so every suggestion got filtered out —
|
|
// the widget showed no suggestions at all, even on an explicit Ctrl+Space re-invoke.
|
|
const pgInfoRef = createQuotedPgInfoRef()
|
|
// Only `OrderD` has been typed so far; the closing quote is auto-closed immediately after it.
|
|
const line = 'select * from test_orders where "OrderD"'
|
|
const word = 'OrderD'
|
|
|
|
const provider = getPgsqlCompletionProvider(monaco, pgInfoRef)
|
|
const context = { triggerCharacter: undefined } as unknown as ProvideCompletionItemsParams[2]
|
|
const token = {} as ProvideCompletionItemsParams[3]
|
|
|
|
const result = provider.provideCompletionItems(
|
|
createQuotedIdentModel(line, word),
|
|
createQuotedIdentPosition(line, word),
|
|
context,
|
|
token
|
|
) as languages.CompletionList
|
|
|
|
const suggestion = result.suggestions.find((s) => s.label === 'OrderDate')
|
|
expect(suggestion?.filterText).toBe('"OrderDate')
|
|
})
|
|
|
|
it('replaces only the pre-existing opening quote instead of doubling it up when the closing quote has been deleted', () => {
|
|
// Regression test for: type `"OrderD` (auto-closed to `"OrderD|"`), delete the closing quote
|
|
// (leaving `"OrderD` with no closing quote at all), then accept the `OrderDate` suggestion —
|
|
// previously produced `""OrderDate` because `isQuoted` required both quotes to be present, so
|
|
// the range left the existing opening quote untouched while the quoted insertText added another.
|
|
const pgInfoRef = createQuotedPgInfoRef()
|
|
const line = 'select * from test_orders where "OrderD'
|
|
const word = 'OrderD'
|
|
|
|
const provider = getPgsqlCompletionProvider(monaco, pgInfoRef)
|
|
const context = { triggerCharacter: undefined } as unknown as ProvideCompletionItemsParams[2]
|
|
const token = {} as ProvideCompletionItemsParams[3]
|
|
|
|
const result = provider.provideCompletionItems(
|
|
createQuotedIdentModel(line, word),
|
|
createQuotedIdentPosition(line, word),
|
|
context,
|
|
token
|
|
) as languages.CompletionList
|
|
|
|
const suggestion = result.suggestions.find((s) => s.label === 'OrderDate')
|
|
expect(suggestion?.insertText).toBe('"OrderDate"')
|
|
expect(suggestion?.filterText).toBe('"OrderDate')
|
|
|
|
// Only the opening quote gets swallowed — there's no closing quote to swallow, so the end
|
|
// boundary stays at the word's own end (the cursor position).
|
|
const quoteStart = line.indexOf('"')
|
|
const range = suggestion?.range as { startColumn: number; endColumn: number }
|
|
expect(range.startColumn).toBe(quoteStart + 1)
|
|
expect(range.endColumn).toBe(line.length + 1)
|
|
})
|
|
|
|
it('still quotes an all-lowercase column when the closing quote is missing', () => {
|
|
// formatInsertText only force-quotes on `isQuoted`, since a mixed-case column already forces
|
|
// quoting via its own `hasUpperCase` check regardless of `isQuoted`. An all-lowercase column
|
|
// has no such fallback, so this exercises `isQuoted` being detected from the opening quote alone.
|
|
const pgInfoRef = createQuotedPgInfoRef()
|
|
pgInfoRef.current.tableColumns[0].columns.push({ attname: 'orderdate', data_type: 'date' })
|
|
const line = 'select * from test_orders where "orderd'
|
|
const word = 'orderd'
|
|
|
|
const provider = getPgsqlCompletionProvider(monaco, pgInfoRef)
|
|
const context = { triggerCharacter: undefined } as unknown as ProvideCompletionItemsParams[2]
|
|
const token = {} as ProvideCompletionItemsParams[3]
|
|
|
|
const result = provider.provideCompletionItems(
|
|
createQuotedIdentModel(line, word),
|
|
createQuotedIdentPosition(line, word),
|
|
context,
|
|
token
|
|
) as languages.CompletionList
|
|
|
|
const suggestion = result.suggestions.find((s) => s.label === 'orderdate')
|
|
expect(suggestion?.insertText).toBe('"orderdate"')
|
|
})
|
|
})
|