Files
dbx/apps/desktop/src/lib/editor/hoverTableSql.ts
T

827 lines
30 KiB
TypeScript

import type { ColumnInfo, IndexInfo } from "@/types/database";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { isSqlKeyword } from "@/lib/sql/sqlNavigation";
export interface HoverTableScope {
catalog?: string;
database: string;
schema?: string;
}
function normalizedScopePart(value: string | null | undefined): string | undefined {
const normalized = value?.trim().toLowerCase();
return normalized || undefined;
}
export function scopeHoverTables(tables: SqlCompletionTable[], scope: HoverTableScope): SqlCompletionTable[] {
return tables.map((table) => ({
...table,
catalog: table.catalog ?? scope.catalog,
database: table.database ?? scope.database,
schema: table.schema ?? scope.schema,
}));
}
export function hoverTableMatchesScope(table: SqlCompletionTable, scope: HoverTableScope): boolean {
if (normalizedScopePart(table.database) !== normalizedScopePart(scope.database)) return false;
if (normalizedScopePart(scope.catalog) && normalizedScopePart(table.catalog) !== normalizedScopePart(scope.catalog)) return false;
if (normalizedScopePart(scope.schema) && normalizedScopePart(table.schema) !== normalizedScopePart(scope.schema)) return false;
return true;
}
/**
* Format a column's data type for display in the hover DDL.
*
* - If the backend already reports a parameterized type (e.g. `varchar(255)`,
* `datetime2(7)`), trust it as-is.
* - For character types with `character_maximum_length`, append the length.
* SQL Server maps `-1` to `(max)`.
* - For numeric types with `numeric_precision` / `numeric_scale`, append the
* precision/scale.
* - Otherwise return the raw `data_type` from the backend unchanged.
*/
function formatColumnType(c: ColumnInfo): string {
const dataType = c.data_type.trim();
// Backend already reports a parameterized type — trust it.
if (/\([^()]*\)$/.test(dataType)) return dataType;
// SQL Server character_maximum_length of -1 means (max)
if (c.character_maximum_length != null && c.numeric_scale == null) {
if (c.character_maximum_length === -1) return `${dataType}(max)`;
return `${dataType}(${c.character_maximum_length})`;
}
if (c.numeric_precision != null && c.numeric_scale != null) {
if (c.numeric_scale === 0) return `${dataType}(${c.numeric_precision})`;
return `${dataType}(${c.numeric_precision},${c.numeric_scale})`;
}
return dataType;
}
/**
* Render a column default value in SQL form.
*
* String literals are kept as-is, numeric values and SQL keywords are
* unquoted, and other bare values are wrapped in single quotes.
* Only called when the backend `column_default` is not null.
*/
function formatDefaultValue(value: string): string {
// Already quoted string literal — keep as-is.
if (/^'/.test(value)) return value;
// Numeric values — no quotes.
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
// Expression default (wrapped in parentheses) — keep as-is.
if (/^\(/.test(value)) return value;
// SQL keywords (NULL, TRUE, FALSE, etc.) — no quotes.
if (isSqlKeyword(value)) return value;
// Well-known SQL functions commonly used as defaults — no quotes.
if (/^(current_timestamp|current_date|current_time|localtimestamp|localtime|now|getdate|random|gen_random_uuid|uuid)\s*(?:\(.*\))?$/i.test(value)) return value;
// Other bare string values — wrap in quotes.
return `'${value.replace(/'/g, "''")}'`;
}
/**
* Strip CHARACTER SET / COLLATE clauses from a raw backend DDL for hover display.
*
* MySQL `SHOW CREATE TABLE` appends `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
* to each VARCHAR/CHAR column and `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
* to table options. These are redundant in a quick-look tooltip and add visual noise.
*
* Only affects the hover display — never modifies the source DDL on the backend.
*/
export function sanitizeHoverDdl(ddl: string): string {
// Table-level patterns run before column-level to consume DEFAULT first,
// preventing MariaDB `DEFAULT CHARACTER SET utf8` from leaving an orphan DEFAULT.
return (
ddl
// Table-level: `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`
.replace(/\s+DEFAULT\s+CHARSET\s*=\s*\w+(?:\s+COLLATE\s*=\s*\w+)?/gi, "")
// Table-level: `DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci` (MariaDB)
.replace(/\s+DEFAULT\s+CHARACTER\s+SET\s+\w+(?:\s+COLLATE\s+\w+)?/gi, "")
// Standalone `CHARSET=utf8mb4`, `CHARACTER SET utf8mb4`, or
// `CHARACTER SET utf8mb4 COLLATE utf8_general_ci` (table- or column-level)
.replace(/\s+CHARACTER\s+SET\s+\w+(?:\s+COLLATE\s+\w+)?/gi, "")
.replace(/\s+CHARSET\s*=\s*\w+/gi, "")
// Standalone `COLLATE=utf8mb4_general_ci` or `COLLATE utf8mb4_general_ci`
.replace(/\s+COLLATE(?:\s*=\s*|\s+)\w+/gi, "")
.trim()
);
}
/**
* The persisted display DDL is the canonical object definition. Native
* PostgreSQL appends owner and grant statements after the structural DDL;
* quick-look hover keeps the structure and omits that access-control tail.
*/
export function ddlForHoverPreview(ddl: string): string {
const accessTail = /\n\s*ALTER\s+TABLE\s+[^\n;]+\s+OWNER\s+TO\s+[^\n;]+;/i.exec(ddl);
return accessTail ? ddl.slice(0, accessTail.index).trimEnd() : ddl;
}
/**
* Display fields of a single column line, prior to vertical alignment.
*/
interface ColumnFieldParts {
name: string;
type: string;
nullable: string; // "not null" or "null"
defaultClause: string; // "default <value>" or empty
extra: string; // extra attributes (auto_increment, etc.) or empty
commentClause: string; // "comment '...'" or empty
}
/**
* Render column rows with each field (name, type, extra, default, nullable,
* comment) vertically aligned across all column lines. Shared by both the
* metadata-based builder and the raw-DDL reformatter so both hover paths
* produce the same visual layout.
*/
function alignColumnRows(rows: ColumnFieldParts[]): string[] {
const maxNameWidth = Math.max(...rows.map((r) => r.name.length));
const maxTypeWidth = Math.max(...rows.map((r) => r.type.length), 4);
const hasExtra = rows.some((r) => r.extra);
const hasDefault = rows.some((r) => r.defaultClause);
const hasComment = rows.some((r) => r.commentClause);
const maxNullableWidth = Math.max(...rows.map((r) => r.nullable.length));
const maxExtraWidth = Math.max(...rows.map((r) => r.extra.length));
const maxDefaultWidth = Math.max(...rows.map((r) => r.defaultClause.length));
// Field order: name → type → [extra] → [defaultClause] → nullable → [commentClause]
return rows.map((r) => {
const parts: string[] = [r.name.padEnd(maxNameWidth), " ", r.type.padEnd(maxTypeWidth)];
if (hasExtra) parts.push(" ", r.extra.padEnd(maxExtraWidth));
if (hasDefault) parts.push(" ", r.defaultClause.padEnd(maxDefaultWidth));
parts.push(" ", r.nullable.padEnd(maxNullableWidth));
if (hasComment) parts.push(" ", r.commentClause);
return parts.join("").trimEnd();
});
}
/**
* Collapse column-alignment padding outside SQL literals and comments, keeping
* each line's leading indentation intact. The hover tooltip renders padding for
* readability, but copied SQL must preserve semantic whitespace inside values.
*/
export function normalizeAlignedSqlWhitespace(text: string): string {
let result = "";
let index = 0;
let lineStart = true;
let blockComment = false;
let dollarQuoteEnd = "";
while (index < text.length) {
if (blockComment) {
const end = text.indexOf("*/", index);
if (end === -1) return result + text.slice(index);
result += text.slice(index, end + 2);
lineStart = text[end + 1] === "\n";
index = end + 2;
blockComment = false;
continue;
}
if (dollarQuoteEnd) {
const end = text.indexOf(dollarQuoteEnd, index);
if (end === -1) return result + text.slice(index);
result += text.slice(index, end + dollarQuoteEnd.length);
lineStart = false;
index = end + dollarQuoteEnd.length;
dollarQuoteEnd = "";
continue;
}
const char = text[index];
const next = text[index + 1];
if (char === "\n" || char === "\r") {
result += char;
index += 1;
lineStart = true;
continue;
}
if (lineStart && (char === " " || char === "\t")) {
result += char;
index += 1;
continue;
}
lineStart = false;
if (char === "-" && next === "-") {
const end = text.indexOf("\n", index);
if (end === -1) return result + text.slice(index);
result += text.slice(index, end);
index = end;
continue;
}
if (char === "/" && next === "*") {
result += "/*";
index += 2;
blockComment = true;
continue;
}
if (char === "$" && (index === 0 || !/[\w$]/.test(text[index - 1]))) {
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(text.slice(index));
if (match) {
result += match[0];
index += match[0].length;
dollarQuoteEnd = match[0];
continue;
}
}
if (char === "'" || char === '"' || char === "`") {
const quote = char;
const start = index;
index += 1;
while (index < text.length) {
if (text[index] === quote) {
if (text[index + 1] === quote) index += 2;
else {
index += 1;
break;
}
} else if (text[index] === "\\" && index + 1 < text.length) {
index += 2;
} else {
index += 1;
}
}
result += text.slice(start, index);
continue;
}
if (char === "[") {
const start = index;
index += 1;
while (index < text.length) {
if (text[index] === "]") {
if (text[index + 1] === "]") index += 2;
else {
index += 1;
break;
}
} else {
index += 1;
}
}
result += text.slice(start, index);
continue;
}
if (char === " ") {
let end = index + 1;
while (text[end] === " ") end += 1;
result += " ";
index = end;
continue;
}
result += char;
index += 1;
}
return result;
}
/**
* Quote a SQL identifier by wrapping it in double quotes with proper escaping.
*/
export function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}
/**
* Quote each segment of a qualified name (e.g. `db.schema.table`).
*/
export function quoteQualifiedName(qualifiedName: string): string {
const segments = qualifiedName.split(".");
return segments.map((seg) => (seg.startsWith('"') ? seg : quoteIdentifier(seg))).join(".");
}
/**
* Build a human-readable `CREATE TABLE` DDL for the hover tooltip.
*
* **Primary path**: the backend's `getTableDdl` API should be preferred when
* available — it returns database-specific DDL (e.g. `SHOW CREATE TABLE`,
* Postgres `pg_ddl`, SQL Server `build_sqlserver_ddl`). This function is the
* **fallback** used when the backend DDL path is unavailable.
*
* The output is formatted for readability in the hover tooltip, not for
* execution. Columns are listed with each field (name, type, nullable,
* default, extra, comment) vertically aligned across all column lines,
* followed by a table-level PRIMARY KEY clause and non-primary index DDL.
*/
export function buildHoverTableSql(qualifiedName: string, columns: ColumnInfo[], indexes: IndexInfo[], tableComment?: string): string {
// Resolve primary key columns from the index (handles composite keys), or
// fall back to column-level primary key flags when the index is absent.
const primaryKeyIndex = indexes.find((idx) => idx.is_primary);
let pkColumns: string[];
if (primaryKeyIndex?.columns?.length) {
pkColumns = primaryKeyIndex.columns;
} else {
pkColumns = columns.filter((c) => c.is_primary_key).map((c) => c.name);
}
// Pre-compute each column's display fields so we can determine column widths
// for vertical alignment.
const rows: ColumnFieldParts[] = columns.map((c) => {
const type = formatColumnType(c);
const nullable = c.is_nullable ? "null" : "not null";
const defaultClause = c.column_default != null ? `default ${formatDefaultValue(c.column_default)}` : "";
const extra = c.extra ?? "";
const commentClause = c.comment ? `comment '${c.comment.replace(/'/g, "''")}'` : "";
return {
name: ` ${quoteIdentifier(c.name)}`,
type,
nullable,
defaultClause,
extra,
commentClause,
};
});
// Build each column line with vertical alignment.
const columnDefs: string[] = alignColumnRows(rows);
// Append the PRIMARY KEY constraint if there are PK columns.
if (pkColumns.length > 0) {
columnDefs.push(` primary key (${pkColumns.map(quoteIdentifier).join(", ")})`);
}
const closeSuffix = tableComment?.trim() ? ` comment '${tableComment.replace(/'/g, "''")}';` : ";";
const ddl = `create table ${qualifiedName} (\n${columnDefs.join(",\n")}\n)${closeSuffix}`;
// Append non-primary index DDL.
const nonPrimaryIndexes = indexes.filter((idx) => !idx.is_primary);
if (nonPrimaryIndexes.length === 0) return ddl;
const indexLines: string[] = [""];
for (const idx of nonPrimaryIndexes) {
const uniquePrefix = idx.is_unique ? "unique " : "";
indexLines.push(`create ${uniquePrefix}index ${quoteIdentifier(idx.name)}`);
indexLines.push(` on ${qualifiedName} (${idx.columns.map((c) => quoteIdentifier(c)).join(", ")});`);
}
return ddl + "\n" + indexLines.join("\n");
}
// ---------------------------------------------------------------------------
// Raw backend DDL parsing & realignment (primary hover path)
// ---------------------------------------------------------------------------
/** Scan past a delimited region (string literal or quoted identifier). */
function scanDelimited(text: string, start: number, close: string): number {
let i = start + 1;
const backslashEscapes = close === "'";
while (i < text.length) {
const ch = text[i];
if (backslashEscapes && ch === "\\") {
i += 2;
continue;
}
if (ch === close) {
// Doubled delimiter is an escape ('' "" `` ]])
if (text[i + 1] === close) {
i += 2;
continue;
}
return i + 1;
}
i++;
}
return text.length;
}
/** Scan past a balanced parenthesized group, respecting quotes. */
function scanGroup(text: string, start: number): number {
let depth = 0;
let i = start;
while (i < text.length) {
const ch = text[i];
if (ch === "'" || ch === '"' || ch === "`") {
i = scanDelimited(text, i, ch);
continue;
}
if (ch === "[") {
i = scanDelimited(text, i, "]");
continue;
}
if (ch === "(") depth++;
else if (ch === ")") {
depth--;
if (depth === 0) return i + 1;
}
i++;
}
return text.length;
}
/** Split text on a separator at parenthesis depth 0, outside quotes. */
function splitTopLevel(text: string, separator: string): string[] {
const parts: string[] = [];
let start = 0;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === "'" || ch === '"' || ch === "`") {
i = scanDelimited(text, i, ch);
continue;
}
if (ch === "[") {
i = scanDelimited(text, i, "]");
continue;
}
if (ch === "(") {
i = scanGroup(text, i);
continue;
}
if (ch === separator) {
parts.push(text.slice(start, i));
start = i + 1;
}
i++;
}
parts.push(text.slice(start));
return parts;
}
interface DdlToken {
text: string;
start: number;
end: number;
}
/**
* Tokenize a DDL fragment into words, quoted identifiers, string literals and
* whole parenthesized groups (each group is a single token).
*/
function tokenizeDdl(text: string): DdlToken[] {
const tokens: DdlToken[] = [];
let i = 0;
while (i < text.length) {
const ch = text[i];
if (/\s/.test(ch)) {
i++;
continue;
}
let end: number;
if (ch === "'" || ch === '"' || ch === "`") end = scanDelimited(text, i, ch);
else if (ch === "[") end = scanDelimited(text, i, "]");
else if (ch === "(") end = scanGroup(text, i);
else {
const m = /^[^\s'"`()[\],]+/.exec(text.slice(i));
end = i + (m ? m[0].length : 1);
}
tokens.push({ text: text.slice(i, end), start: i, end });
i = end;
}
return tokens;
}
/** Remove backtick / double-quote / bracket quoting from an identifier. */
function unquoteIdentifier(raw: string): string {
const t = raw.trim();
if (t.startsWith("`") && t.endsWith("`")) return t.slice(1, -1).replace(/``/g, "`");
if (t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1).replace(/""/g, '"');
if (t.startsWith("[") && t.endsWith("]")) return t.slice(1, -1).replace(/]]/g, "]");
return t;
}
/** Remove single-quote wrapping and unescape a SQL string literal. */
function unquoteSqlString(raw: string): string {
const t = raw.trim();
if (t.startsWith("'") && t.endsWith("'")) {
return t.slice(1, -1).replace(/''/g, "'").replace(/\\'/g, "'");
}
return t;
}
/** Quote a parsed key column unless it is an expression (functional index). */
function quoteKeyColumn(col: string): string {
return /^[\w$#]+$/.test(col) ? quoteIdentifier(col) : col;
}
interface ParsedDdlColumn {
name: string;
type: string;
nullable: string;
defaultClause: string;
extra: string;
comment: string;
}
interface ParsedIndex {
name: string;
columns: string[];
unique: boolean;
}
interface ParsedCreateTable {
qualifiedName: string;
columns: ParsedDdlColumn[];
pkColumns: string[];
constraintLines: string[];
indexes: ParsedIndex[];
tableComment?: string;
}
// Keywords that terminate the data type and introduce a column clause.
const COLUMN_CLAUSE_BOUNDARIES = new Set(["not", "null", "default", "auto_increment", "comment", "generated", "primary", "unique", "references", "check", "on", "constraint", "identity", "stored", "virtual", "invisible", "visible", "collate"]);
// A body line starting with one of these keywords is a table-level constraint.
const CONSTRAINT_LINE_KEYWORDS = new Set(["primary", "unique", "key", "index", "constraint", "foreign", "check", "fulltext", "spatial", "exclude", "period", "like"]);
/** Parse an index/PK column list, dropping prefix lengths and ASC/DESC. */
function parseKeyColumns(group: string): string[] {
return splitTopLevel(group, ",")
.map((part) => {
let p = part.trim();
p = p.replace(/\s+(asc|desc)$/i, "");
p = p.replace(/\(\d+\)$/, ""); // MySQL prefix length: `email`(20)
return unquoteIdentifier(p.trim());
})
.filter(Boolean);
}
/** Parse one column definition line of a CREATE TABLE body. */
function parseColumnLine(line: string): { column: ParsedDdlColumn; inlinePk: boolean } | null {
const tokens = tokenizeDdl(line);
if (tokens.length < 2) return null;
const name = unquoteIdentifier(tokens[0].text);
const tokenAt = (i: number) => tokens[i]?.text.toLowerCase();
// Everything before the first clause keyword is the data type
// (e.g. `int(11) unsigned`, `character varying(255)`, `timestamp with time zone`).
let boundary = tokens.length;
for (let i = 1; i < tokens.length; i++) {
if (COLUMN_CLAUSE_BOUNDARIES.has(tokenAt(i)!)) {
boundary = i;
break;
}
}
if (boundary <= 1) return null;
const type = line.slice(tokens[1].start, tokens[boundary - 1].end);
let nullable = "null";
let defaultClause = "";
let comment = "";
let inlinePk = false;
const extras: string[] = [];
let k = boundary;
while (k < tokens.length) {
const t = tokenAt(k)!;
if (t === "not" && tokenAt(k + 1) === "null") {
nullable = "not null";
k += 2;
continue;
}
if (t === "null") {
nullable = "null";
k += 1;
continue;
}
if (t === "default" && k + 1 < tokens.length) {
// The first value token is always part of the expression (handles
// DEFAULT NULL); subsequent tokens run until the next clause keyword.
let end = k + 2;
while (end < tokens.length && !COLUMN_CLAUSE_BOUNDARIES.has(tokenAt(end)!)) end++;
defaultClause = `default ${line.slice(tokens[k + 1].start, tokens[end - 1].end)}`;
k = end;
continue;
}
if (t === "comment" && k + 1 < tokens.length) {
comment = unquoteSqlString(tokens[k + 1].text);
k += 2;
continue;
}
if (t === "primary" && tokenAt(k + 1) === "key") {
inlinePk = true;
k += 2;
continue;
}
if (t === "unique") {
extras.push("unique");
k += tokenAt(k + 1) === "key" ? 2 : 1;
continue;
}
// Unrecognized clause (AUTO_INCREMENT, ON UPDATE ..., GENERATED ... AS (...),
// IDENTITY(1,1), REFERENCES ..., ...): keep the keyword and any following
// non-keyword tokens verbatim as extra attributes.
let end = k + 1;
while (end < tokens.length && !COLUMN_CLAUSE_BOUNDARIES.has(tokenAt(end)!)) end++;
extras.push(line.slice(tokens[k].start, tokens[end - 1].end));
k = end;
}
return {
column: { name, type, nullable, defaultClause, extra: extras.join(" "), comment },
inlinePk,
};
}
/** Parse one table-level constraint line of a CREATE TABLE body. */
function parseConstraintLine(line: string, parsed: ParsedCreateTable): void {
let rest = line.trim();
const named = /^constraint\s+(?:`(?:``|[^`])*`|"(?:""|[^"])*"|\[(?:]]|[^\]])*]|\S+)\s+/i.exec(rest);
const namedPrefixLength = named ? named[0].length : 0;
rest = rest.slice(namedPrefixLength);
const tokens = tokenizeDdl(rest);
const tokenAt = (i: number) => tokens[i]?.text.toLowerCase();
const groupIdx = tokens.findIndex((tok) => tok.text.startsWith("("));
const groupColumns = groupIdx >= 0 ? parseKeyColumns(tokens[groupIdx].text.slice(1, -1)) : [];
if (tokenAt(0) === "primary" && tokenAt(1) === "key" && groupColumns.length > 0) {
parsed.pkColumns.push(...groupColumns);
return;
}
if (tokenAt(0) === "unique" && (tokenAt(1) === "key" || tokenAt(1) === "index") && groupIdx >= 2 && groupColumns.length > 0) {
parsed.indexes.push({ name: unquoteIdentifier(tokens[groupIdx - 1].text), columns: groupColumns, unique: true });
return;
}
if ((tokenAt(0) === "key" || tokenAt(0) === "index") && groupIdx >= 2 && groupColumns.length > 0) {
parsed.indexes.push({ name: unquoteIdentifier(tokens[groupIdx - 1].text), columns: groupColumns, unique: false });
return;
}
// FOREIGN KEY / CHECK / EXCLUDE / unnamed UNIQUE / FULLTEXT ... — preserve verbatim.
parsed.constraintLines.push(line.trim());
}
/** Parse a single CREATE TABLE statement. Returns null when unparseable. */
function parseCreateTableStatement(statement: string): ParsedCreateTable | null {
const header = /^\s*create\s+(?:or\s+replace\s+)?(?:global\s+|local\s+|temporary\s+|temp\s+|unlogged\s+|external\s+)*table\s+(?:if\s+not\s+exists\s+)?/i.exec(statement);
if (!header) return null;
const rest = statement.slice(header[0].length);
const tokens = tokenizeDdl(rest);
const bodyIdx = tokens.findIndex((tok) => tok.text.startsWith("("));
if (bodyIdx <= 0) return null;
const rawName = rest.slice(tokens[0].start, tokens[bodyIdx - 1].end);
const qualifiedName = splitTopLevel(rawName, ".")
.map((seg) => quoteIdentifier(unquoteIdentifier(seg.trim())))
.join(".");
const parsed: ParsedCreateTable = {
qualifiedName,
columns: [],
pkColumns: [],
constraintLines: [],
indexes: [],
};
const body = tokens[bodyIdx].text.slice(1, -1);
for (const rawLine of splitTopLevel(body, ",")) {
const line = rawLine.trim();
if (!line) continue;
const firstWord = /^[A-Za-z_]+/.exec(line)?.[0].toLowerCase();
if (firstWord && CONSTRAINT_LINE_KEYWORDS.has(firstWord)) {
parseConstraintLine(line, parsed);
continue;
}
const col = parseColumnLine(line);
// An unparseable column line means the whole reconstruction is unsafe —
// bail out so the caller falls back to the sanitized raw DDL.
if (!col) return null;
parsed.columns.push(col.column);
if (col.inlinePk) parsed.pkColumns.push(col.column.name);
}
// Table options: keep only the COMMENT, drop ENGINE / AUTO_INCREMENT / ... noise.
const optionsText = rest.slice(tokens[bodyIdx].end);
const cm = /comment\s*=?\s*('(?:\\'|''|[^'])*')/i.exec(optionsText);
if (cm) parsed.tableComment = unquoteSqlString(cm[1]);
return parsed;
}
/** Parse a standalone CREATE [UNIQUE] INDEX statement. */
function parseCreateIndexStatement(statement: string): ParsedIndex | null {
const header = /^\s*create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?/i.exec(statement);
if (!header) return null;
const rest = statement.slice(header[0].length);
const tokens = tokenizeDdl(rest);
if (tokens.length < 3 || tokens[1].text.toLowerCase() !== "on") return null;
const groupIdx = tokens.findIndex((tok) => tok.text.startsWith("("));
if (groupIdx < 2) return null;
const columns = parseKeyColumns(tokens[groupIdx].text.slice(1, -1));
if (columns.length === 0) return null;
return { name: unquoteIdentifier(tokens[0].text), columns, unique: !!header[1] };
}
/** Render a parsed CREATE TABLE with buildHoverTableSql's aligned layout. */
function renderParsedCreateTable(parsed: ParsedCreateTable, qualifiedNameOverride: string | undefined, passthroughStatements: string[]): string {
const displayName = qualifiedNameOverride ?? parsed.qualifiedName;
const rows: ColumnFieldParts[] = parsed.columns.map((c) => ({
name: ` ${quoteIdentifier(c.name)}`,
type: c.type,
nullable: c.nullable,
defaultClause: c.defaultClause,
extra: c.extra,
commentClause: c.comment ? `comment '${c.comment.replace(/'/g, "''")}'` : "",
}));
const columnDefs = alignColumnRows(rows);
if (parsed.pkColumns.length > 0) {
columnDefs.push(` primary key (${parsed.pkColumns.map(quoteKeyColumn).join(", ")})`);
}
for (const constraintLine of parsed.constraintLines) {
columnDefs.push(` ${constraintLine}`);
}
const closeSuffix = parsed.tableComment?.trim() ? ` comment '${parsed.tableComment.replace(/'/g, "''")}';` : ";";
let ddl = `create table ${displayName} (\n${columnDefs.join(",\n")}\n)${closeSuffix}`;
if (parsed.indexes.length > 0) {
const indexLines: string[] = [""];
for (const idx of parsed.indexes) {
const uniquePrefix = idx.unique ? "unique " : "";
indexLines.push(`create ${uniquePrefix}index ${quoteIdentifier(idx.name)}`);
indexLines.push(` on ${displayName} (${idx.columns.map(quoteKeyColumn).join(", ")});`);
}
ddl += "\n" + indexLines.join("\n");
}
if (passthroughStatements.length > 0) {
ddl += "\n\n" + passthroughStatements.join("\n");
}
return ddl;
}
/**
* Reformat a raw backend DDL (`getTableDdl`) for the hover tooltip.
*
* This is the **primary** hover path: the backend DDL is authoritative
* (SHOW CREATE TABLE, pg_ddl, build_sqlserver_ddl, ...). The DDL is first
* stripped of charset/COLLATE noise, then parsed into structured fields and
* rebuilt with the same vertical alignment as {@link buildHoverTableSql}
* (name, type, extra, default, nullable, comment). Companion statements are
* merged in: `COMMENT ON TABLE/COLUMN` become inline comments and
* `CREATE INDEX` statements are re-emitted after the table.
*
* When the DDL cannot be parsed confidently, the sanitized raw DDL is
* returned unchanged so no information is ever lost.
*/
export function reformatHoverDdl(rawDdl: string, qualifiedName?: string): string {
const sanitized = sanitizeHoverDdl(rawDdl);
const statements = splitTopLevel(sanitized, ";")
.map((s) => s.trim())
.filter(Boolean);
// Companion statements can carry database-specific semantics that this
// lightweight formatter cannot reproduce losslessly (for example Postgres
// USING / INCLUDE / WHERE index clauses). Keep the sanitized backend DDL.
if (statements.length !== 1) return sanitized;
const createIdx = statements.findIndex((s) => /^\s*create\s+(?:or\s+replace\s+)?(?:global\s+|local\s+|temporary\s+|temp\s+|unlogged\s+|external\s+)*table\b/i.test(s));
if (createIdx === -1) return sanitized;
const parsed = parseCreateTableStatement(statements[createIdx]);
if (!parsed || parsed.columns.length === 0) return sanitized;
// Table suffixes such as PARTITION BY, DISTRIBUTED BY, ENGINE, TABLESPACE,
// and storage options are backend-specific. Rebuilding them partially would
// silently remove structure, so only reformat a CREATE TABLE whose suffix is
// empty or consists solely of a table COMMENT that the parser preserves.
const createStatement = statements[createIdx];
const header = /^\s*create\s+(?:or\s+replace\s+)?(?:global\s+|local\s+|temporary\s+|temp\s+|unlogged\s+|external\s+)*table\s+(?:if\s+not\s+exists\s+)?/i.exec(createStatement);
const rest = header ? createStatement.slice(header[0].length) : "";
const bodyToken = tokenizeDdl(rest).find((token) => token.text.startsWith("("));
const suffix = bodyToken ? rest.slice(bodyToken.end).trim() : "";
const suffixWithoutComment = suffix.replace(/^comment\s*=?\s*('(?:\\'|''|[^'])*')\s*$/i, "").trim();
if (suffixWithoutComment) return sanitized;
const passthrough: string[] = [];
for (let i = 0; i < statements.length; i++) {
if (i === createIdx) continue;
const statement = statements[i];
const tableCommentMatch = /^comment\s+on\s+table\s+.+?\s+is\s+('(?:\\'|''|[^'])*')\s*$/is.exec(statement);
if (tableCommentMatch) {
parsed.tableComment ??= unquoteSqlString(tableCommentMatch[1]);
continue;
}
const columnCommentMatch = /^comment\s+on\s+column\s+(\S+)\s+is\s+('(?:\\'|''|[^'])*')\s*$/is.exec(statement);
if (columnCommentMatch) {
const segments = splitTopLevel(columnCommentMatch[1], ".");
const columnName = unquoteIdentifier(segments[segments.length - 1] ?? "");
const column = parsed.columns.find((c) => c.name.toLowerCase() === columnName.toLowerCase());
if (column) {
if (!column.comment) column.comment = unquoteSqlString(columnCommentMatch[2]);
continue;
}
}
const index = parseCreateIndexStatement(statement);
if (index) {
parsed.indexes.push(index);
continue;
}
passthrough.push(`${statement};`);
}
return renderParsedCreateTable(parsed, qualifiedName, passthrough);
}