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 -->
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
import type { Monaco } from '@monaco-editor/react'
|
|
import type { editor, languages, Position } from 'monaco-editor'
|
|
import type { RefObject } from 'react'
|
|
|
|
import BackwardIterator from './BackwardIterator'
|
|
import {
|
|
filterTablesByReferences,
|
|
getFromClauseTables,
|
|
getStatementAtOffset,
|
|
parseTrailingDotIdent,
|
|
resolveTablesForIdent,
|
|
} from './PgSQLCompletionProvider.utils'
|
|
import type { PgInfo } from './Providers.types'
|
|
import type { SavedDatabaseFunction } from '@/data/database-functions/database-functions-query'
|
|
import type { Schema } from '@/data/database/schemas-query'
|
|
import type { TableColumn } from '@/data/database/table-columns-query'
|
|
|
|
type TableColumnField = {
|
|
attname: string
|
|
data_type: string
|
|
}
|
|
|
|
type QuotableIdent = {
|
|
isQuoted: boolean
|
|
name: string
|
|
}
|
|
|
|
const EMPTY_IDENT: QuotableIdent = { isQuoted: false, name: '' }
|
|
|
|
export function getPgsqlCompletionProvider(
|
|
monaco: Monaco,
|
|
pgInfoRef: RefObject<PgInfo | null>
|
|
): languages.CompletionItemProvider {
|
|
return {
|
|
triggerCharacters: [' ', '.', '"'],
|
|
provideCompletionItems: function (
|
|
model: editor.ITextModel,
|
|
position: Position,
|
|
context: languages.CompletionContext
|
|
) {
|
|
try {
|
|
const pgInfo = pgInfoRef.current
|
|
if (!pgInfo) return { suggestions: [] }
|
|
|
|
// position.column should minus 2 as it returns 2 for first char
|
|
// position.lineNumber should minus 1
|
|
const iterator = new BackwardIterator(model, position.column - 2, position.lineNumber - 1)
|
|
const { range, isQuoted } = getReplacementRange(model, position)
|
|
const statement = getStatementAtOffset(model.getValue(), model.getOffsetAt(position))
|
|
|
|
if (context.triggerCharacter === '"') {
|
|
return startingQuoteScenarioSuggestions(monaco, pgInfo, iterator, range, isQuoted)
|
|
} else if (context.triggerCharacter === '.') {
|
|
return dotScenarioSuggestions(monaco, pgInfo, iterator, range, statement, isQuoted)
|
|
} else {
|
|
return defaultScenarioSuggestions(monaco, pgInfo, statement, range, isQuoted)
|
|
}
|
|
} catch (_) {
|
|
// any error, returns empty suggestion
|
|
return { suggestions: [] }
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// Monaco requires a range on every completion item; when one isn't supplied it falls back to
|
|
// replacing the current word anyway, so we compute that explicitly rather than leave it implicit.
|
|
//
|
|
// `"` is a word separator for Monaco, so `getWordUntilPosition` never includes surrounding double
|
|
// quotes in the word it finds. When the identifier being completed already sits next to one —
|
|
// typed by hand, auto-closed by the editor as you type the opening quote, or with the closing
|
|
// quote since deleted (e.g. `"OrderD` at end of line, no closing quote at all) — we expand the
|
|
// range to swallow whichever quote(s) are actually there and flag `isQuoted` so callers always
|
|
// emit fully-quoted insertText. Each side is checked independently: swallowing only the side that
|
|
// has a quote (and leaving the other side's boundary untouched when there's nothing to swallow)
|
|
// is what stops a quoted insertText from landing next to an untouched pre-existing quote and
|
|
// doubling it up.
|
|
function getReplacementRange(
|
|
model: editor.ITextModel,
|
|
position: Position
|
|
): { range: languages.CompletionItem['range']; isQuoted: boolean } {
|
|
const word = model.getWordUntilPosition(position)
|
|
const line = model.getLineContent(position.lineNumber)
|
|
const hasLeadingQuote = line.charAt(word.startColumn - 2) === '"'
|
|
const hasTrailingQuote = line.charAt(word.endColumn - 1) === '"'
|
|
const isQuoted = hasLeadingQuote || hasTrailingQuote
|
|
|
|
return {
|
|
range: {
|
|
startLineNumber: position.lineNumber,
|
|
endLineNumber: position.lineNumber,
|
|
startColumn: hasLeadingQuote ? word.startColumn - 1 : word.startColumn,
|
|
endColumn: hasTrailingQuote ? word.endColumn + 1 : word.endColumn,
|
|
},
|
|
isQuoted,
|
|
}
|
|
}
|
|
|
|
function startingQuoteScenarioSuggestions(
|
|
monaco: Monaco,
|
|
pgInfo: PgInfo,
|
|
iterator: BackwardIterator,
|
|
range: languages.CompletionItem['range'],
|
|
isQuoted: boolean
|
|
) {
|
|
const items: languages.CompletionItem[] = []
|
|
|
|
const startingQuotedIdent = iterator.isFowardDQuote()
|
|
if (!startingQuotedIdent) return { suggestions: items }
|
|
|
|
iterator.next() // get passed the starting quote
|
|
if (iterator.isNextPeriod()) {
|
|
// probably a field - get the ident
|
|
let ident = iterator.readIdent()
|
|
let isQuotedIdent = false
|
|
if (ident.match(/^\".*?\"$/)) {
|
|
isQuotedIdent = true
|
|
ident = fixQuotedIdent(ident)
|
|
}
|
|
const table = pgInfo.tableColumns.find((tbl: TableColumn) => {
|
|
return (
|
|
(isQuotedIdent && tbl.tablename === ident) ||
|
|
(!isQuotedIdent && tbl.tablename.toLocaleLowerCase() == ident.toLocaleLowerCase())
|
|
)
|
|
})
|
|
|
|
if (!table) return { suggestions: items }
|
|
table.columns.forEach((field: TableColumnField | null) => {
|
|
if (!field) return
|
|
items.push({
|
|
label: field.attname,
|
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
detail: field.data_type,
|
|
insertText: formatInsertText(field.attname, isQuoted),
|
|
filterText: formatFilterText(field.attname, isQuoted),
|
|
range,
|
|
})
|
|
})
|
|
} else {
|
|
// probably a table - list the tables
|
|
pgInfo.tableColumns.forEach((table: TableColumn) => {
|
|
items.push({
|
|
label: table.tablename,
|
|
kind: monaco.languages.CompletionItemKind.Class,
|
|
insertText: formatInsertText(table.tablename, isQuoted),
|
|
filterText: formatFilterText(table.tablename, isQuoted),
|
|
range,
|
|
})
|
|
})
|
|
}
|
|
|
|
return { suggestions: items }
|
|
}
|
|
|
|
function dotScenarioSuggestions(
|
|
monaco: Monaco,
|
|
pgInfo: PgInfo,
|
|
iterator: BackwardIterator,
|
|
range: languages.CompletionItem['range'],
|
|
statement: string,
|
|
isQuoted: boolean
|
|
) {
|
|
const items: languages.CompletionItem[] = []
|
|
|
|
const idents = readIdents(iterator, 3)
|
|
let pos = 0
|
|
|
|
let schema = pgInfo.schemas.find((sch: Schema) => {
|
|
const _ident = idents.length > pos ? idents[pos] : EMPTY_IDENT
|
|
return (
|
|
(_ident.isQuoted && sch.name === _ident.name) ||
|
|
(!_ident.isQuoted && sch.name.toLocaleLowerCase() == _ident.name.toLocaleLowerCase())
|
|
)
|
|
})
|
|
|
|
// A schema can't be part of an alias (`myschema.c.id` isn't valid SQL), so only
|
|
// attempt alias resolution when nothing before this ident was consumed as a schema.
|
|
// This must happen before the `public`-schema fallback/early-return below, so `c.`
|
|
// resolves via the FROM/JOIN alias even in a database with no `public` schema.
|
|
if (!schema) {
|
|
const tableIdent = idents.length > pos ? idents[pos] : EMPTY_IDENT
|
|
const aliasedTables = resolveTablesForIdent(
|
|
pgInfo.tableColumns,
|
|
getFromClauseTables(statement),
|
|
tableIdent
|
|
)
|
|
if (aliasedTables.length > 0) {
|
|
aliasedTables[0].columns.forEach((field: TableColumnField | null) => {
|
|
if (!field) return
|
|
items.push({
|
|
label: field.attname,
|
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
detail: field.data_type,
|
|
insertText: formatInsertText(field.attname, isQuoted),
|
|
filterText: formatFilterText(field.attname, isQuoted),
|
|
range,
|
|
})
|
|
})
|
|
return { suggestions: items }
|
|
}
|
|
} else {
|
|
pos++
|
|
}
|
|
|
|
if (!schema) {
|
|
schema = pgInfo.schemas.find((sch: Schema) => sch.name == 'public')
|
|
}
|
|
|
|
// No custom schema and no `public` schema either — nothing sensible to suggest.
|
|
if (!schema) return { suggestions: items }
|
|
|
|
if (idents.length == pos) {
|
|
pgInfo.tableColumns.forEach((tbl: TableColumn) => {
|
|
if (tbl.schemaname != schema.name) {
|
|
return
|
|
}
|
|
items.push({
|
|
label: tbl.tablename,
|
|
kind: monaco.languages.CompletionItemKind.Class,
|
|
detail: tbl.schemaname !== 'public' ? tbl.schemaname : undefined,
|
|
insertText: formatInsertText(tbl.tablename, isQuoted),
|
|
filterText: formatFilterText(tbl.tablename, isQuoted),
|
|
range,
|
|
})
|
|
})
|
|
return { suggestions: items }
|
|
}
|
|
|
|
const tableIdent = idents.length > pos ? idents[pos] : EMPTY_IDENT
|
|
|
|
const table = pgInfo.tableColumns.find((tbl: TableColumn) => {
|
|
if (tbl.schemaname !== schema.name) return false
|
|
return tableIdent.isQuoted
|
|
? tbl.tablename === tableIdent.name
|
|
: tbl.tablename.toLocaleLowerCase() == tableIdent.name.toLocaleLowerCase()
|
|
})
|
|
|
|
if (table) {
|
|
table.columns.forEach((field: TableColumnField | null) => {
|
|
if (!field) return
|
|
items.push({
|
|
label: field.attname,
|
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
detail: field.data_type,
|
|
insertText: formatInsertText(field.attname, isQuoted),
|
|
filterText: formatFilterText(field.attname, isQuoted),
|
|
range,
|
|
})
|
|
})
|
|
}
|
|
|
|
return { suggestions: items }
|
|
}
|
|
|
|
function defaultScenarioSuggestions(
|
|
monaco: Monaco,
|
|
pgInfo: PgInfo,
|
|
statement: string,
|
|
range: languages.CompletionItem['range'],
|
|
isQuoted: boolean
|
|
) {
|
|
const items: languages.CompletionItem[] = []
|
|
|
|
pgInfo.keywords.forEach((x: string) => {
|
|
items.push({
|
|
label: x,
|
|
kind: monaco.languages.CompletionItemKind.Keyword,
|
|
insertText: x,
|
|
range,
|
|
})
|
|
})
|
|
|
|
pgInfo.schemas.forEach((x: Schema) => {
|
|
items.push({
|
|
label: x.name,
|
|
kind: monaco.languages.CompletionItemKind.Keyword,
|
|
insertText: x.name,
|
|
range,
|
|
})
|
|
})
|
|
|
|
const allTableColumns = pgInfo.tableColumns
|
|
|
|
allTableColumns.forEach((x: TableColumn) => {
|
|
const insertText = x.schemaname == 'public' ? x.tablename : x.schemaname + '.' + x.tablename
|
|
items.push({
|
|
label: x.tablename,
|
|
detail: x.schemaname !== 'public' ? x.schemaname : undefined,
|
|
kind: x.is_table
|
|
? monaco.languages.CompletionItemKind.Class
|
|
: monaco.languages.CompletionItemKind.Interface,
|
|
insertText: formatInsertText(insertText, isQuoted),
|
|
filterText: formatFilterText(x.tablename, isQuoted),
|
|
range,
|
|
})
|
|
})
|
|
|
|
// Narrow column suggestions down to the table(s) referenced in the current
|
|
// statement's FROM/JOIN clauses (e.g. `select * from colors where |` should
|
|
// only suggest colors' columns). Falls back to every table's columns when
|
|
// none can be resolved, e.g. before a FROM clause has been typed.
|
|
const fromClauseTables = getFromClauseTables(statement)
|
|
|
|
// If the statement ends with `alias.` (Monaco can re-request suggestions this way
|
|
// right after the dot, without going through the dot-trigger scenario), scope
|
|
// strictly to that one alias/table instead of every FROM/JOIN table — otherwise
|
|
// e.g. `c.` after `orders o join customers c` would suggest orders' columns too.
|
|
const trailingDotIdent = parseTrailingDotIdent(statement)
|
|
const aliasScopedTableColumns = trailingDotIdent
|
|
? resolveTablesForIdent(allTableColumns, fromClauseTables, trailingDotIdent)
|
|
: []
|
|
|
|
const inScopeTableColumns =
|
|
aliasScopedTableColumns.length > 0
|
|
? aliasScopedTableColumns
|
|
: filterTablesByReferences(allTableColumns, fromClauseTables)
|
|
const hasResolvedFromTables = inScopeTableColumns.length > 0
|
|
const relevantTableColumns = hasResolvedFromTables ? inScopeTableColumns : allTableColumns
|
|
|
|
// Monaco sorts suggestions by sortText (falling back to label) when nothing has been typed
|
|
// yet, so without this, columns get buried alphabetically amongst keywords/functions. Rank
|
|
// them first once we know which table(s) are in scope; leave sorting untouched otherwise
|
|
// (e.g. before FROM is typed). Columns sharing a name+type across in-scope tables are merged
|
|
// into one suggestion, tracked here rather than on the item itself so the item stays a plain
|
|
// Monaco CompletionItem.
|
|
const columnItemsByKey = new Map<string, { item: languages.CompletionItem; tables: string[] }>()
|
|
|
|
relevantTableColumns.forEach((x: TableColumn) => {
|
|
x.columns.forEach((field: TableColumnField | null) => {
|
|
if (!field) return
|
|
|
|
const key = `${field.attname}::${field.data_type}`
|
|
const existing = columnItemsByKey.get(key)
|
|
if (existing) {
|
|
existing.tables.push(x.tablename)
|
|
existing.tables.sort()
|
|
existing.item.documentation = existing.tables.join(', ')
|
|
} else {
|
|
const item: languages.CompletionItem = {
|
|
label: field.attname,
|
|
kind: monaco.languages.CompletionItemKind.Field,
|
|
detail: field.data_type,
|
|
documentation: x.tablename,
|
|
insertText: formatInsertText(field.attname, isQuoted),
|
|
filterText: formatFilterText(field.attname, isQuoted),
|
|
range,
|
|
sortText: hasResolvedFromTables ? `0_${field.attname}` : undefined,
|
|
}
|
|
columnItemsByKey.set(key, { item, tables: [x.tablename] })
|
|
items.push(item)
|
|
}
|
|
})
|
|
})
|
|
|
|
pgInfo.functions.forEach((x: SavedDatabaseFunction) => {
|
|
items.push({
|
|
label: x.name,
|
|
kind: monaco.languages.CompletionItemKind.Function,
|
|
detail: x.return_type,
|
|
insertText: x.name,
|
|
range,
|
|
})
|
|
})
|
|
|
|
return { suggestions: items }
|
|
}
|
|
|
|
function fixQuotedIdent(str: string) {
|
|
return str.replace(/^\"/, '').replace(/\"$/, '').replace(/\"\"/, '"')
|
|
}
|
|
|
|
function readIdents(iterator: BackwardIterator, maxlvl: number): QuotableIdent[] {
|
|
return iterator.readIdents(maxlvl).map((name: string) => {
|
|
let isQuoted = false
|
|
if (name.match(/^\".*?\"$/)) {
|
|
isQuoted = true
|
|
name = fixQuotedIdent(name)
|
|
}
|
|
return { isQuoted, name }
|
|
})
|
|
}
|
|
|
|
function formatInsertText(value: string, forceQuote = false) {
|
|
const hasUpperCase = !(value == value.toLowerCase())
|
|
return hasUpperCase || forceQuote ? `"${value}"` : value
|
|
}
|
|
|
|
// Monaco filters/scores suggestions by fuzzy-matching the label against the text between the
|
|
// completion item's range start and the cursor. When `isQuoted` is true that range start sits on
|
|
// the literal opening `"` (see getReplacementRange), so the typed prefix looks like `"OrderD` while
|
|
// a plain label like `OrderDate` has no quote to match — every suggestion gets filtered out, even
|
|
// on an explicit Ctrl+Space re-invoke. Mirroring the leading quote into `filterText` keeps it aligned
|
|
// with what's actually been typed.
|
|
function formatFilterText(value: string, isQuoted: boolean) {
|
|
return isQuoted ? `"${value}` : value
|
|
}
|