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 -->
239 lines
7.0 KiB
TypeScript
239 lines
7.0 KiB
TypeScript
// Heuristic (non-AST) helpers used to scope column suggestions to the table(s)
|
|
// referenced by the SQL statement the cursor is currently in, e.g. so that
|
|
// `select * from colors where |` only suggests `colors`' columns.
|
|
|
|
export type SqlTableReference = {
|
|
schema?: string
|
|
name: string
|
|
alias?: string
|
|
}
|
|
|
|
export type SqlIdent = {
|
|
isQuoted: boolean
|
|
name: string
|
|
}
|
|
|
|
const CLAUSE_STOP_KEYWORDS =
|
|
'where|group|order|having|limit|offset|union|intersect|except|join|inner|left|right|full|cross|lateral|natural|window|for|returning|set|on|using'
|
|
|
|
const JOIN_START = '(?:(?:inner|left|right|full|cross|lateral|natural)\\s+(?:outer\\s+)?)?join'
|
|
|
|
const IDENT = '(?:"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)'
|
|
|
|
const TABLE_REF_PATTERN = new RegExp(
|
|
`^(${IDENT})(?:\\s*\\.\\s*(${IDENT}))?(?:\\s+(?:as\\s+)?(${IDENT}))?$`,
|
|
'i'
|
|
)
|
|
|
|
function unquoteIdent(raw: string): string {
|
|
const trimmed = raw.trim()
|
|
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
return trimmed.slice(1, -1).replace(/""/g, '"')
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
// Splits a comma-separated list of table references on top-level commas only
|
|
// (ignores commas nested inside parentheses, e.g. function-call table sources).
|
|
function splitTopLevelCommas(text: string): string[] {
|
|
const parts: string[] = []
|
|
let depth = 0
|
|
let current = ''
|
|
|
|
for (const char of text) {
|
|
if (char === '(') depth++
|
|
if (char === ')') depth--
|
|
if (char === ',' && depth === 0) {
|
|
parts.push(current)
|
|
current = ''
|
|
continue
|
|
}
|
|
current += char
|
|
}
|
|
parts.push(current)
|
|
return parts
|
|
}
|
|
|
|
function parseTableRef(segment: string): SqlTableReference | null {
|
|
const trimmed = segment.trim()
|
|
if (!trimmed || trimmed.startsWith('(')) return null
|
|
|
|
const match = trimmed.match(TABLE_REF_PATTERN)
|
|
if (!match) return null
|
|
|
|
const [, first, second, alias] = match
|
|
if (second) {
|
|
return {
|
|
schema: unquoteIdent(first),
|
|
name: unquoteIdent(second),
|
|
alias: alias ? unquoteIdent(alias) : undefined,
|
|
}
|
|
}
|
|
return { name: unquoteIdent(first), alias: alias ? unquoteIdent(alias) : undefined }
|
|
}
|
|
|
|
function parseTableRefList(segment: string): SqlTableReference[] {
|
|
return splitTopLevelCommas(segment)
|
|
.map(parseTableRef)
|
|
.filter((ref): ref is SqlTableReference => ref !== null)
|
|
}
|
|
|
|
function matchClauseSegments(statement: string, startPattern: string): string[] {
|
|
const pattern = new RegExp(
|
|
`\\b${startPattern}\\b([\\s\\S]*?)(?=\\b(?:${CLAUSE_STOP_KEYWORDS})\\b|;|$)`,
|
|
'gi'
|
|
)
|
|
const segments: string[] = []
|
|
let match: RegExpExecArray | null
|
|
while ((match = pattern.exec(statement)) !== null) {
|
|
segments.push(match[1])
|
|
// avoid infinite loops on zero-length matches
|
|
if (match[1].length === 0) pattern.lastIndex++
|
|
}
|
|
return segments
|
|
}
|
|
|
|
// Extracts the table (and join) references from the `FROM`/`JOIN` clauses of
|
|
// a single SQL statement. Best-effort: subquery table sources and function
|
|
// table sources are skipped rather than mis-parsed.
|
|
export function getFromClauseTables(statement: string): SqlTableReference[] {
|
|
const refs: SqlTableReference[] = []
|
|
|
|
for (const segment of matchClauseSegments(statement, 'from')) {
|
|
refs.push(...parseTableRefList(segment))
|
|
}
|
|
for (const segment of matchClauseSegments(statement, JOIN_START)) {
|
|
refs.push(...parseTableRefList(segment))
|
|
}
|
|
|
|
return refs
|
|
}
|
|
|
|
// Returns the SQL statement that contains `offset`, where statements are
|
|
// delimited by semicolons that are not inside a string literal or comment.
|
|
export function getStatementAtOffset(sql: string, offset: number): string {
|
|
let start = 0
|
|
let end = sql.length
|
|
let inSingleQuote = false
|
|
let inDoubleQuote = false
|
|
let inLineComment = false
|
|
let inBlockComment = false
|
|
|
|
for (let i = 0; i < sql.length; i++) {
|
|
const char = sql[i]
|
|
const next = sql[i + 1]
|
|
|
|
if (inLineComment) {
|
|
if (char === '\n') inLineComment = false
|
|
continue
|
|
}
|
|
if (inBlockComment) {
|
|
if (char === '*' && next === '/') {
|
|
inBlockComment = false
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if (inSingleQuote) {
|
|
if (char === "'") {
|
|
if (next === "'") {
|
|
i++
|
|
} else {
|
|
inSingleQuote = false
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if (inDoubleQuote) {
|
|
if (char === '"') {
|
|
if (next === '"') {
|
|
i++
|
|
} else {
|
|
inDoubleQuote = false
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (char === "'") {
|
|
inSingleQuote = true
|
|
} else if (char === '"') {
|
|
inDoubleQuote = true
|
|
} else if (char === '-' && next === '-') {
|
|
inLineComment = true
|
|
i++
|
|
} else if (char === '/' && next === '*') {
|
|
inBlockComment = true
|
|
i++
|
|
} else if (char === ';') {
|
|
if (i < offset) {
|
|
start = i + 1
|
|
} else {
|
|
end = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return sql.slice(start, end)
|
|
}
|
|
|
|
// Filters a list of tables (or table-columns) down to just the ones referenced
|
|
// in `refs`, matching on table name (case-insensitive) and, when a ref
|
|
// specifies a schema, on schema too.
|
|
export function filterTablesByReferences<T extends { schemaname: string; tablename: string }>(
|
|
tables: T[],
|
|
refs: SqlTableReference[]
|
|
): T[] {
|
|
if (refs.length === 0) return []
|
|
|
|
return tables.filter((table) =>
|
|
refs.some((ref) => {
|
|
if (table.tablename.toLowerCase() !== ref.name.toLowerCase()) return false
|
|
if (ref.schema) return table.schemaname.toLowerCase() === ref.schema.toLowerCase()
|
|
return true
|
|
})
|
|
)
|
|
}
|
|
|
|
// Matches a trailing `ident.` (quoted or unquoted), capturing the identifier.
|
|
const TRAILING_DOT_IDENT_PATTERN = new RegExp(`(${IDENT})\\s*\\.\\s*$`)
|
|
|
|
// Detects an identifier immediately followed by a dot at the very end of the
|
|
// text (e.g. `... where c.`), so completion can be scoped to that specific
|
|
// alias/table rather than every table referenced in the statement.
|
|
export function parseTrailingDotIdent(text: string): SqlIdent | null {
|
|
const match = text.match(TRAILING_DOT_IDENT_PATTERN)
|
|
if (!match) return null
|
|
|
|
const raw = match[1]
|
|
return { isQuoted: raw.startsWith('"'), name: unquoteIdent(raw) }
|
|
}
|
|
|
|
// Resolves `ident` (typically the identifier typed right before a dot)
|
|
// against `refs`, matching on alias first and falling back to the table's own
|
|
// name for un-aliased references — so `c.` after `... customers c` (or
|
|
// `customers.` with no alias) both resolve to the `customers` table, but a
|
|
// join's other table doesn't leak in.
|
|
export function resolveTablesForIdent<T extends { schemaname: string; tablename: string }>(
|
|
tables: T[],
|
|
refs: SqlTableReference[],
|
|
ident: SqlIdent
|
|
): T[] {
|
|
const matchingRefs = refs.filter((ref) => {
|
|
const candidate = ref.alias ?? ref.name
|
|
return ident.isQuoted
|
|
? candidate === ident.name
|
|
: candidate.toLocaleLowerCase() === ident.name.toLocaleLowerCase()
|
|
})
|
|
if (matchingRefs.length === 0) return []
|
|
|
|
return tables.filter((table) =>
|
|
matchingRefs.some((ref) => {
|
|
if (table.tablename.toLowerCase() !== ref.name.toLowerCase()) return false
|
|
if (ref.schema) return table.schemaname.toLowerCase() === ref.schema.toLowerCase()
|
|
return true
|
|
})
|
|
)
|
|
}
|