feat(postgres): complete sequence names in regclass literals
Closes #4005
This commit is contained in:
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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user