feat(postgres): complete sequence names in regclass literals

Closes #4005
This commit is contained in:
t8y2 committed 2026-08-13 02:54:18 +00:00
1 parent a67bcb09db
commit 91e7463ee5
11 files changed
+580 -24

No files matched your search

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildSelectStarExpansion, buildSqlCompletionItems, getSqlCompletionContext, selectStarResultColumnsMatch, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion";
import { buildPostgresSequenceLiteralCompletionItems, buildSelectStarExpansion, buildSqlCompletionItems, getPostgresSequenceLiteralCompletionContext, getSqlCompletionContext, selectStarResultColumnsMatch, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion";
import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts } from "@/lib/sql/sqlCompletionTriggerPolicy";
@@ -17,6 +17,96 @@ describe("sqlCompletion keyword snippets", () => {
});
});
describe("PostgreSQL sequence literal completion", () => {
it.each(["nextval", "currval", "setval"])("recognizes the first %s regclass literal", (functionName) => {
const sql = `SELECT ${functionName}('public.order_`;
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(context).toEqual(
expect.objectContaining({
prefix: "order_",
schema: "public",
schemaQuoted: false,
nameQuoted: false,
}),
);
expect(context?.from).toBe(sql.lastIndexOf("order_"));
expect(shouldAutoOpenSqlCompletion(sql, sql.length, { databaseType: "postgres" })).toBe(true);
});
it("preserves quoted mixed-case schema and sequence identifiers", () => {
const sql = `SELECT pg_catalog.nextval('"App"."Order`;
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(context).toEqual(
expect.objectContaining({
prefix: "Order",
schema: "App",
schemaQuoted: true,
nameQuoted: true,
nameQuoteClosed: false,
}),
);
expect(
buildPostgresSequenceLiteralCompletionItems(context!, [
{ name: "OrderSequence", schema: "App", type: "sequence" },
{ name: "order_sequence", schema: "App", type: "sequence" },
]),
).toEqual([
expect.objectContaining({
label: "OrderSequence",
apply: 'OrderSequence"',
replaceClosingQuote: '"',
detail: "sequence in App",
}),
]);
});
it("keeps doubled apostrophes inside the sequence identifier and escapes insertion", () => {
const sql = `SELECT nextval('"customer''s_`;
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(context).toEqual(expect.objectContaining({ prefix: "customer's_", nameQuoted: true }));
expect(buildPostgresSequenceLiteralCompletionItems(context!, [{ name: "customer's_seq", schema: "public", type: "sequence" }])).toEqual([expect.objectContaining({ label: "customer's_seq", apply: `customer''s_seq"` })]);
});
it("quotes an accepted mixed-case identifier in an unquoted literal", () => {
const sql = "SELECT nextval('mix";
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(buildPostgresSequenceLiteralCompletionItems(context!, [{ name: "MixedSequence", schema: "public", type: "sequence" }])).toEqual([expect.objectContaining({ label: "MixedSequence", filterText: "MixedSequence", apply: '"MixedSequence"' })]);
});
it("does not expose sequence metadata through ordinary SQL object completion", () => {
const sql = "SELECT order_";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [],
objects: [{ name: "order_seq", schema: "public", type: "sequence" }],
columnsByTable: new Map(),
databaseType: "postgres",
dialect: "postgres",
});
expect(items.some((item) => item.label === "order_seq")).toBe(false);
});
it.each([
["SELECT 'order_", "postgres"],
["SELECT nextval('order_", "mysql"],
["SELECT nextval('order_') || 'suffix", "postgres"],
["SELECT setval(42, 'order_", "postgres"],
["SELECT app.nextval('order_", "postgres"],
["SELECT app. nextval('order_", "postgres"],
["SELECT app.pg_catalog.nextval('order_", "postgres"],
[`SELECT "PG_CATALOG".nextval('order_`, "postgres"],
["SELECT $$ nextval('order_", "postgres"],
["SELECT $body$ nextval('order_", "postgres"],
] as const)("does not enable sequence completion for unrelated literals: %s (%s)", (sql, databaseType) => {
expect(getPostgresSequenceLiteralCompletionContext(sql, sql.length, databaseType)).toBeNull();
expect(shouldAutoOpenSqlCompletion(sql, sql.length, { databaseType })).toBe(false);
});
});
describe("SELECT star expansion", () => {
it("reuses completion column ordering for an unqualified star", () => {
const sql = "SELECT * FROM apis";
+216 -2
View File
@@ -1172,7 +1172,7 @@ export interface SqlCompletionTable {
export interface SqlCompletionObject {
name: string;
schema?: string;
type: "procedure" | "function" | "trigger" | "package";
type: "procedure" | "function" | "trigger" | "package" | "sequence";
parentSchema?: string;
parentName?: string;
dataType?: string;
@@ -1208,6 +1208,7 @@ export interface SqlCompletionItem {
detail?: string;
info?: string;
apply?: string;
replaceClosingQuote?: '"' | "'";
boost: number;
exactMatch?: boolean;
dedupeKey?: string;
@@ -1271,6 +1272,15 @@ export interface SqlCompletionContext {
dataTypeContext: boolean;
}
export interface PostgresSequenceLiteralCompletionContext {
from: number;
prefix: string;
schema?: string;
schemaQuoted: boolean;
nameQuoted: boolean;
nameQuoteClosed: boolean;
}
export interface SqlFunctionSignatureHelpOverload {
signature: string;
parameterGroups: string[][];
@@ -1478,6 +1488,7 @@ class SqlCompletionProvider {
}
export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): boolean {
if (getPostgresSequenceLiteralCompletionContext(sql, cursor, options.databaseType)) return true;
if (isSqlCompletionSuppressedContext(sql, cursor)) return false;
const previousChar = sql[cursor - 1];
if (!previousChar) return false;
@@ -1511,6 +1522,209 @@ export function isSqlCompletionSuppressedContext(sql: string, cursor: number): b
return context.inLineComment || context.inBlockComment || context.inStringLiteral;
}
export function getPostgresSequenceLiteralCompletionContext(sql: string, cursor: number, databaseType?: DatabaseType): PostgresSequenceLiteralCompletionContext | null {
if (databaseType !== "postgres") return null;
const position = Math.max(0, Math.min(cursor, sql.length));
const literalStart = activeSingleQuotedLiteralStart(sql, position);
if (literalStart == null) return null;
const beforeLiteral = sql.slice(0, literalStart);
if (!isPostgresSequenceFunctionCall(beforeLiteral)) return null;
const rawLiteral = sql.slice(literalStart + 1, position);
const parsed = parsePostgresRegclassPrefix(rawLiteral);
if (!parsed) return null;
return {
from: literalStart + 1 + parsed.nameStart,
prefix: parsed.name,
schema: parsed.schema,
schemaQuoted: parsed.schemaQuoted,
nameQuoted: parsed.nameQuoted,
nameQuoteClosed: parsed.nameQuoteClosed,
};
}
export function buildPostgresSequenceLiteralCompletionItems(context: PostgresSequenceLiteralCompletionContext, objects: SqlCompletionObject[]): SqlCompletionItem[] {
return objects
.filter((object) => object.type === "sequence")
.filter((object) => {
if (context.schema) {
if (!object.schema) return false;
const schemaMatches = context.schemaQuoted ? object.schema === context.schema : object.schema.toLowerCase() === context.schema.toLowerCase();
if (!schemaMatches) return false;
}
return context.nameQuoted ? object.name.startsWith(context.prefix) : object.name.toLowerCase().startsWith(context.prefix.toLowerCase());
})
.map((object) => {
const identifier = context.nameQuoted ? `${escapePostgresSequenceLiteralQuotedIdentifier(object.name)}"` : escapePostgresSequenceLiteralIdentifier(quoteSqlIdentifier(object.name, "postgres"));
return {
label: object.name,
filterText: context.nameQuoted ? identifier.slice(0, -1) : escapePostgresSequenceLiteralIdentifier(object.name),
type: "variable" as const,
detail: object.schema ? `sequence in ${object.schema}` : "sequence",
info: object.comment?.trim() || undefined,
apply: identifier,
replaceClosingQuote: context.nameQuoted && !context.nameQuoteClosed ? ('"' as const) : undefined,
boost: computeBoost(object.name, context.prefix) + 1_200,
};
})
.sort(compareCompletionItems)
.slice(0, MAX_TABLE_COMPLETION_ITEMS);
}
function escapePostgresSequenceLiteralIdentifier(value: string): string {
return value.replaceAll("'", "''");
}
function escapePostgresSequenceLiteralQuotedIdentifier(value: string): string {
return escapePostgresSequenceLiteralIdentifier(value.replaceAll('"', '""'));
}
function isPostgresSequenceFunctionCall(beforeLiteral: string): boolean {
const call = /(?:nextval|currval|setval)\s*\(\s*$/i.exec(beforeLiteral);
if (!call) return false;
const beforeFunction = beforeLiteral.slice(0, call.index);
const beforeFunctionTrimmed = beforeFunction.trimEnd();
if (!beforeFunctionTrimmed.endsWith(".")) {
return !beforeFunction || /\s$/.test(beforeFunction) || !/[\w$."`]$/.test(beforeFunction);
}
const beforeDot = beforeFunctionTrimmed.slice(0, -1).trimEnd();
const qualifier = /(?:"((?:""|[^"])*)"|([A-Za-z_][\w$]*))$/.exec(beforeDot);
if (!qualifier) return false;
const quotedQualifier = qualifier[1];
const qualifierName = (quotedQualifier ?? qualifier[2] ?? "").replaceAll('""', '"');
if (quotedQualifier != null ? qualifierName !== "pg_catalog" : qualifierName.toLowerCase() !== "pg_catalog") return false;
const beforeQualifier = beforeDot.slice(0, qualifier.index);
return !beforeQualifier || /\s$/.test(beforeQualifier) || !/[\w$."`]$/.test(beforeQualifier);
}
function activeSingleQuotedLiteralStart(sql: string, cursor: number): number | null {
let literalStart: number | null = null;
let dollarQuoteDelimiter: string | null = null;
let inDoubleQuote = false;
let inLineComment = false;
let inBlockComment = false;
for (let index = 0; index < cursor; index += 1) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (dollarQuoteDelimiter) {
if (sql.startsWith(dollarQuoteDelimiter, index)) {
index += dollarQuoteDelimiter.length - 1;
dollarQuoteDelimiter = null;
}
continue;
}
if (inLineComment) {
if (char === "\n" || char === "\r") inLineComment = false;
continue;
}
if (inBlockComment) {
if (char === "*" && next === "/") {
inBlockComment = false;
index++;
}
continue;
}
if (literalStart != null) {
if (char === "'" && next === "'") {
index++;
} else if (char === "'") {
literalStart = null;
}
continue;
}
if (inDoubleQuote) {
if (char === '"' && next === '"') index++;
else if (char === '"') inDoubleQuote = false;
continue;
}
if (char === "-" && next === "-") {
inLineComment = true;
index++;
} else if (char === "/" && next === "*") {
inBlockComment = true;
index++;
} else if (char === '"') {
inDoubleQuote = true;
} else if (char === "$") {
const delimiter = /^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(index, cursor))?.[0];
if (delimiter) {
dollarQuoteDelimiter = delimiter;
index += delimiter.length - 1;
}
} else if (char === "'") {
literalStart = index;
}
}
return literalStart;
}
function parsePostgresRegclassPrefix(raw: string): { nameStart: number; name: string; schema?: string; schemaQuoted: boolean; nameQuoted: boolean; nameQuoteClosed: boolean } | null {
const parts: Array<{ start: number; value: string; quoted: boolean; quoteClosed: boolean }> = [];
let index = 0;
while (index < raw.length && /\s/.test(raw[index] ?? "")) index++;
while (index <= raw.length && parts.length < 2) {
const start = index;
let quoted = false;
let quoteClosed = false;
let value = "";
if (raw[index] === '"') {
quoted = true;
index++;
const valueStart = index;
while (index < raw.length) {
const char = raw[index] ?? "";
if (char === '"' && raw[index + 1] === '"') {
value += '"';
index += 2;
} else if (char === '"') {
quoteClosed = true;
index++;
break;
} else if (char === "'" && raw[index + 1] === "'") {
value += "'";
index += 2;
} else {
value += char;
index++;
}
}
parts.push({ start: valueStart, value, quoted, quoteClosed });
} else {
while (index < raw.length && raw[index] !== ".") {
const char = raw[index] ?? "";
if (char === "'" && raw[index + 1] === "'") {
value += "'";
index += 2;
} else if (/\s/.test(char) || char === '"') {
return null;
} else {
value += char;
index++;
}
}
parts.push({ start, value, quoted, quoteClosed });
}
if (index >= raw.length) break;
if (raw[index] !== "." || (!quoteClosed && quoted)) return null;
index++;
if (index >= raw.length) parts.push({ start: index, value: "", quoted: false, quoteClosed: false });
}
if (index < raw.length || parts.length === 0 || parts.length > 2) return null;
const name = parts[parts.length - 1]!;
const schema = parts.length === 2 ? parts[0] : undefined;
return {
nameStart: name.start,
name: name.value,
schema: schema?.value ? (schema.quoted ? schema.value : schema.value.toLowerCase()) : undefined,
schemaQuoted: schema?.quoted ?? false,
nameQuoted: name.quoted,
nameQuoteClosed: name.quoteClosed,
};
}
export function isSqlStringLiteralContext(sql: string, cursor: number): boolean {
return getSqlLexicalContext(sql, cursor).inStringLiteral;
}
@@ -3057,7 +3271,7 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO
const onlyFunctions = context.suggestColumns && context.referencedTables.length > 0 && !context.qualifier;
const prioritizeOracleFunctions = databaseType === "oracle" && context.statementKind === "select";
return objects
.filter((object) => (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context))
.filter((object) => object.type !== "sequence" && (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context))
.map((object) => {
const qualifiedByContext = objectIsQualifiedByContext(object, context);
const objectInCurrentSchema = !!currentSchema && !!object.schema && normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema);