refactor(desktop): organize lib modules by domain

This commit is contained in:
t8y2 committed 2026-07-04 13:53:52 +08:00
1 parent 90f0594e93
commit 7e7c4944e9
700 files changed
+1292 -1264

No files matched your search

@@ -0,0 +1,196 @@
import type { ObjectInfo } from "@/types/database";
import { createDatabaseObjectNameComparator, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
export type ObjectBrowserRow = {
id: string;
name: string;
displayName: string;
schema?: string;
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY";
signature?: string | null;
comment?: string | null;
created_at?: string | null;
updated_at?: string | null;
partitionParentId?: string;
partitionCount?: number;
partitionParentSchema?: string;
partitionParentName?: string;
estimatedRows?: number | null;
totalBytes?: number | null;
};
export type ObjectBrowserSortKey = "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
export type ObjectBrowserSortDirection = "asc" | "desc";
export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] {
const value = type.toUpperCase();
const normalized = value.replace(/[\s-]+/g, "_");
if (normalized.includes("PACKAGE_BODY")) return "PACKAGE_BODY";
if (normalized.includes("PACKAGE")) return "PACKAGE";
if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW";
if (value.includes("VIEW")) return "VIEW";
if (value.includes("SEQ")) return "SEQUENCE";
if (value.includes("PROC")) return "PROCEDURE";
if (value.includes("FUNC")) return "FUNCTION";
return "TABLE";
}
export function buildObjectBrowserRows(options: { objects: ObjectInfo[]; database: string; fallbackSchema: string; needsSchema: boolean }): ObjectBrowserRow[] {
const seen = new Map<string, number>();
const rows = options.objects.flatMap((object) => {
const name = normalizeDatabaseObjectName(object.name);
if (!name) return [];
const objectSchema = object.schema ? normalizeDatabaseObjectName(object.schema) : undefined;
const schema = objectSchema || (options.needsSchema ? options.fallbackSchema : undefined);
const type = normalizeObjectBrowserType(object.object_type);
const signature = routineSignatureForDisplay(type, object.signature);
const displayName = signature === undefined ? name : `${name}(${signature})`;
const signatureIdPart = signature === undefined ? "" : `:${signature}`;
const baseId = `${schema || options.fallbackSchema || options.database}:${name}:${type}${signatureIdPart}`;
const index = seen.get(baseId) ?? 0;
seen.set(baseId, index + 1);
return [
{
id: `${baseId}:${index}`,
name,
displayName,
schema,
type,
signature,
comment: object.comment,
created_at: object.created_at,
updated_at: object.updated_at,
partitionParentSchema: object.parent_schema ? normalizeDatabaseObjectName(object.parent_schema) : undefined,
partitionParentName: object.parent_name ? normalizeDatabaseObjectName(object.parent_name) : undefined,
},
];
});
markPartitionRows(rows, options.fallbackSchema || options.database);
return rows;
}
function routineSignatureForDisplay(type: ObjectBrowserRow["type"], signature: string | null | undefined): string | undefined {
if (type !== "PROCEDURE" && type !== "FUNCTION") return undefined;
if (signature == null) return undefined;
return signature.trim();
}
function markPartitionRows(rows: ObjectBrowserRow[], fallbackSchema: string) {
const tableByKey = new Map<string, ObjectBrowserRow>();
for (const row of rows) {
if (row.type !== "TABLE") continue;
tableByKey.set(objectKey(row, fallbackSchema), row);
}
const partitionCountByParent = new Map<string, number>();
for (const row of rows) {
if (row.type !== "TABLE") continue;
const parentName = row.partitionParentName || partitionParentName(row.name);
if (!parentName) continue;
const parentKey = objectKey({ ...row, schema: row.partitionParentSchema || row.schema, name: parentName }, fallbackSchema);
const parent = tableByKey.get(parentKey);
if (!parent || parent.id === row.id) continue;
row.partitionParentId = parent.id;
partitionCountByParent.set(parent.id, (partitionCountByParent.get(parent.id) ?? 0) + 1);
}
for (const row of rows) {
row.partitionCount = partitionCountByParent.get(row.id);
}
}
function objectKey(row: Pick<ObjectBrowserRow, "schema" | "name" | "type">, fallbackSchema: string) {
return `${row.type}\0${(row.schema || fallbackSchema).toLowerCase()}\0${row.name.toLowerCase()}`;
}
function partitionParentName(name: string): string | null {
const match = /^(.*)_p\d{4,}$/.exec(name);
return match?.[1] || null;
}
export function filterObjectBrowserRows(rows: ObjectBrowserRow[], query: string): ObjectBrowserRow[] {
const q = query.trim().toLowerCase();
if (!q) return rows;
const regex = parseSlashDelimitedRegexQuery(query.trim());
if (regex) {
return rows.filter((row) => [row.displayName, row.name, row.type, row.comment].filter(Boolean).some((value) => regex.test(String(value))));
}
return rows.filter((row) => [row.displayName, row.name, row.type, row.comment].filter(Boolean).some((value) => String(value).toLowerCase().includes(q)));
}
export function sortObjectBrowserRows(rows: ObjectBrowserRow[], key: ObjectBrowserSortKey, direction: ObjectBrowserSortDirection): ObjectBrowserRow[] {
const multiplier = direction === "asc" ? 1 : -1;
const compareNames = createDatabaseObjectNameComparator(rows.map((row) => row.displayName));
return [...rows].sort((left, right) => {
const compared = key === "name" ? compareNames(left.displayName, right.displayName) : compareObjectBrowserValue(left[key], right[key], key, direction);
if (compared !== 0) return compared * multiplier;
return compareNames(left.displayName, right.displayName);
});
}
export function initialObjectBrowserSortDirection(key: ObjectBrowserSortKey): ObjectBrowserSortDirection {
return key === "created_at" || key === "updated_at" || key === "estimatedRows" || key === "totalBytes" ? "desc" : "asc";
}
export function formatObjectBrowserTimestamp(value: string | null | undefined): string {
const text = value?.trim();
if (!text) return "";
return text
.replace("T", " ")
.replace(/\.\d+(?=$|[+-]\d{2}(?::?\d{2})?$)/, "")
.replace(/(?:Z|[+-]\d{2}(?::?\d{2})?)$/, "");
}
export function formatObjectBrowserCount(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "";
return new Intl.NumberFormat().format(value);
}
export function formatObjectBrowserBytes(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let size = Math.max(0, value);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const fractionDigits = unitIndex === 0 || size >= 100 ? 0 : size >= 10 ? 1 : 2;
return `${size.toFixed(fractionDigits)} ${units[unitIndex]}`;
}
function compareObjectBrowserValue(left: string | number | null | undefined, right: string | number | null | undefined, key: ObjectBrowserSortKey, direction: ObjectBrowserSortDirection): number {
if (key === "estimatedRows" || key === "totalBytes") {
return compareObjectBrowserNumber(left, right, direction);
}
const leftText = normalizeSortValue(left);
const rightText = normalizeSortValue(right);
if (!leftText && !rightText) return 0;
if (!leftText) return direction === "asc" ? 1 : -1;
if (!rightText) return direction === "asc" ? -1 : 1;
if (key === "created_at" || key === "updated_at") return leftText.localeCompare(rightText);
return leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: "base" });
}
function compareObjectBrowserNumber(left: string | number | null | undefined, right: string | number | null | undefined, direction: ObjectBrowserSortDirection): number {
const leftNumber = normalizeSortNumber(left);
const rightNumber = normalizeSortNumber(right);
if (leftNumber === null && rightNumber === null) return 0;
if (leftNumber === null) return direction === "asc" ? 1 : -1;
if (rightNumber === null) return direction === "asc" ? -1 : 1;
return leftNumber - rightNumber;
}
function normalizeSortValue(value: string | number | null | undefined): string {
if (typeof value === "number") return String(value);
return value?.trim() ?? "";
}
function normalizeSortNumber(value: string | number | null | undefined): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string" || !value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
@@ -0,0 +1,33 @@
import type { DatabaseObjectType, DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
export type RenameableObjectType = DatabaseObjectType;
export interface BuildRenameObjectSqlOptions {
databaseType?: DatabaseType;
objectType: RenameableObjectType;
schema?: string | null;
oldName: string;
newName: string;
}
const postgresLikeRenameTypes = new Set<DatabaseType>(["postgres", "redshift", "gaussdb", "kwdb", "kingbase", "highgo", "vastbase"]);
const oracleLikeRenameTypes = new Set<DatabaseType>(["oracle", "dameng"]);
export function supportsObjectRename(databaseType: DatabaseType | undefined, objectType: RenameableObjectType): boolean {
if (!databaseType) return false;
if (databaseType === "sqlserver") return true;
if (objectType === "PROCEDURE" || objectType === "FUNCTION") {
return false;
}
if (databaseType === "sqlite" || databaseType === "rqlite" || databaseType === "turso") return objectType === "TABLE";
if (databaseType === "mysql" || databaseType === "goldendb") return objectType === "TABLE" || objectType === "VIEW";
if (postgresLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW";
if (oracleLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW";
return false;
}
export function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): Promise<string> {
return api.buildRenameObjectSql(options);
}
@@ -0,0 +1,46 @@
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
import * as api from "@/lib/backend/api";
export type BuildEditableObjectSourceSqlInput = {
databaseType: DatabaseType;
objectType: ObjectSourceKind;
schema?: string | null;
name: string;
source: string;
};
export type BuildRoutineRenameObjectSourceInput = BuildEditableObjectSourceSqlInput & {
newName: string;
};
export type ObjectSourceSaveExecutionMode = "single" | "script";
const postgresLikeRoutineRenameTypes = new Set<DatabaseType>(["postgres", "redshift", "gaussdb", "kwdb", "kingbase", "highgo", "vastbase"]);
const mysqlLikeRoutineRenameTypes = new Set<DatabaseType>(["mysql", "goldendb"]);
const oracleLikeRoutineRenameTypes = new Set<DatabaseType>(["oracle", "dameng"]);
export function supportsSourceBackedRoutineRename(databaseType: DatabaseType | undefined, objectType: ObjectSourceKind): boolean {
if (objectType !== "FUNCTION" && objectType !== "PROCEDURE") return false;
if (!databaseType || databaseType === "sqlserver") return false;
return mysqlLikeRoutineRenameTypes.has(databaseType) || postgresLikeRoutineRenameTypes.has(databaseType) || oracleLikeRoutineRenameTypes.has(databaseType);
}
export function buildRoutineRenameObjectSourceStatements(input: BuildRoutineRenameObjectSourceInput): Promise<string[]> {
return api.buildRoutineRenameObjectSourceStatements(input);
}
export function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput): Promise<string[]> {
return api.buildExecutableObjectSourceStatements(input);
}
export async function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput): Promise<string> {
return api.buildExecutableObjectSourceSql(input);
}
export function buildEditableObjectSource(input: BuildEditableObjectSourceSqlInput): Promise<string> {
return api.buildEditableObjectSource(input);
}
export function objectSourceSaveExecutionMode(_databaseType: DatabaseType): ObjectSourceSaveExecutionMode {
return "single";
}
@@ -0,0 +1,121 @@
import type { DatabaseType } from "@/types/database";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
export interface BuildRoutineExecutionSqlOptions {
databaseType?: DatabaseType;
schema?: string;
routineName: string;
}
export type RoutineParameterMode = "IN" | "OUT" | "INOUT" | "RETURN" | "UNKNOWN";
export interface RoutineParameter {
name: string;
dataType: string;
mode: RoutineParameterMode;
ordinal: number;
hasDefault?: boolean;
defaultValue?: string | null;
}
export interface RoutineParameterValue extends RoutineParameter {
value: string;
useNull?: boolean;
useDefault?: boolean;
}
export function qualifiedRoutineName(options: BuildRoutineExecutionSqlOptions): string {
const { databaseType, schema, routineName } = options;
if (databaseType === "databend") return routineName;
if (isSchemaAware(databaseType) && schema) {
return `${quoteTableIdentifier(databaseType, schema)}.${quoteTableIdentifier(databaseType, routineName)}`;
}
return quoteTableIdentifier(databaseType, routineName);
}
export function buildProcedureExecutionSql(options: BuildRoutineExecutionSqlOptions): string {
return buildProcedureExecutionSqlFromValues({ ...options, parameters: [] });
}
export function buildProcedureExecutionSqlFromValues(options: BuildRoutineExecutionSqlOptions & { parameters: RoutineParameterValue[] }): string {
const routine = qualifiedRoutineName(options);
const sortedParameters = [...options.parameters].sort((a, b) => a.ordinal - b.ordinal);
const values = sortedParameters.filter((parameter) => shouldIncludeParameter(parameter));
const useNamedArguments = shouldUseNamedArguments(options.databaseType, sortedParameters);
if (options.databaseType === "sqlserver") {
const args = values.map((parameter) => `${sqlServerParameterName(parameter.name)} = ${routineParameterSqlValue(options.databaseType, parameter)}`).join(", ");
return args ? `EXEC ${routine} ${args};` : `EXEC ${routine};`;
}
if (options.databaseType === "oracle" || options.databaseType === "dameng" || options.databaseType === "oceanbase-oracle") {
return `BEGIN\n ${routine}(${values.map((parameter) => routineArgumentSql(options.databaseType, parameter, useNamedArguments)).join(", ")});\nEND;`;
}
if (options.databaseType === "databend") {
return `CALL PROCEDURE ${routine}(${values.map((parameter) => routineArgumentSql(options.databaseType, parameter, useNamedArguments)).join(", ")});`;
}
return `CALL ${routine}(${values.map((parameter) => routineArgumentSql(options.databaseType, parameter, useNamedArguments)).join(", ")});`;
}
export function shouldIncludeParameter(parameter: RoutineParameterValue): boolean {
if (parameter.useDefault && parameter.hasDefault) return false;
return acceptsRoutineInput(parameter);
}
export function acceptsRoutineInput(parameter: Pick<RoutineParameterValue, "mode">): boolean {
return parameter.mode === "IN" || parameter.mode === "INOUT" || parameter.mode === "UNKNOWN";
}
export function routineParameterSqlValue(databaseType: DatabaseType | undefined, parameter: RoutineParameterValue): string {
if (parameter.useNull) return "NULL";
const raw = parameter.value;
if (raw.trim() === "") return "NULL";
if (looksLikeNumericType(parameter.dataType)) return raw.trim();
if (looksLikeBooleanType(parameter.dataType)) return normalizeBooleanLiteral(raw, databaseType);
return quoteSqlString(raw);
}
function sqlServerParameterName(name: string): string {
return name.startsWith("@") ? name : `@${name}`;
}
function routineArgumentSql(databaseType: DatabaseType | undefined, parameter: RoutineParameterValue, useNamedArguments: boolean): string {
const value = routineParameterSqlValue(databaseType, parameter);
if (!useNamedArguments) return value;
return `${quoteTableIdentifier(databaseType, parameter.name)} => ${value}`;
}
function shouldUseNamedArguments(databaseType: DatabaseType | undefined, sortedParameters: RoutineParameterValue[]): boolean {
if (databaseType !== "postgres" && databaseType !== "oracle" && databaseType !== "dameng" && databaseType !== "oceanbase-oracle") {
return false;
}
let omittedDefault = false;
for (const parameter of sortedParameters) {
if (parameter.useDefault && parameter.hasDefault && acceptsRoutineInput(parameter)) {
omittedDefault = true;
continue;
}
if (omittedDefault && shouldIncludeParameter(parameter)) return sortedParameters.every((item) => !!item.name);
}
return false;
}
function quoteSqlString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function looksLikeNumericType(dataType: string): boolean {
return /\b(bigint|int|integer|smallint|tinyint|serial|number|numeric|decimal|dec|float|double|real|money)\b/i.test(dataType);
}
function looksLikeBooleanType(dataType: string): boolean {
return /\b(bool|boolean|bit)\b/i.test(dataType);
}
function normalizeBooleanLiteral(value: string, databaseType: DatabaseType | undefined): string {
const normalized = value.trim().toLowerCase();
const truthy = normalized === "true" || normalized === "t" || normalized === "yes" || normalized === "y" || normalized === "1";
const falsy = normalized === "false" || normalized === "f" || normalized === "no" || normalized === "n" || normalized === "0";
if (!truthy && !falsy) return quoteSqlString(value);
if (databaseType === "sqlserver") return truthy ? "1" : "0";
return truthy ? "TRUE" : "FALSE";
}
@@ -0,0 +1,205 @@
import * as api from "@/lib/backend/api";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { RoutineParameter, RoutineParameterMode } from "@/lib/table/routineExecutionSql";
export interface LoadRoutineParametersOptions {
connectionId: string;
database: string;
databaseType?: DatabaseType;
schema?: string;
routineName: string;
}
export async function loadRoutineParameters(options: LoadRoutineParametersOptions): Promise<RoutineParameter[]> {
const sql = routineParametersQuery(options);
if (!sql) return [];
const result = await api.executeQuery(options.connectionId, options.database, sql, options.schema, undefined, {
maxRows: 200,
pageSize: 200,
});
return routineParametersFromResult(result, options.databaseType);
}
export function supportsRoutineParameterMetadata(databaseType?: DatabaseType): boolean {
return databaseType === "postgres" || databaseType === "mysql" || databaseType === "doris" || databaseType === "starrocks" || databaseType === "sqlserver" || databaseType === "oracle" || databaseType === "dameng" || databaseType === "oceanbase-oracle" || databaseType === "databend";
}
export function routineParametersQuery(options: Pick<LoadRoutineParametersOptions, "database" | "databaseType" | "schema" | "routineName">): string | null {
if (!supportsRoutineParameterMetadata(options.databaseType)) return null;
const effectiveSchema = options.schema || (options.databaseType === "postgres" ? "public" : "") || (options.databaseType === "mysql" || options.databaseType === "doris" || options.databaseType === "starrocks" ? options.database : "");
const schema = quoteSqlLiteral(effectiveSchema);
const name = quoteSqlLiteral(options.routineName);
if (options.databaseType === "postgres") {
return `
SELECT
NULLIF(arg.name, '') AS name,
arg.data_type,
CASE arg.mode
WHEN 'i' THEN 'IN'
WHEN 'o' THEN 'OUT'
WHEN 'b' THEN 'INOUT'
WHEN 'v' THEN 'IN'
WHEN 't' THEN 'OUT'
ELSE 'IN'
END AS mode,
arg.ordinal,
CASE
WHEN COALESCE(arg.mode, 'i') IN ('i', 'b', 'v') AND p.pronargdefaults > 0 AND arg.input_ordinal > p.pronargs - p.pronargdefaults THEN TRUE
ELSE FALSE
END AS has_default
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
CROSS JOIN LATERAL (
SELECT
gs.ordinal AS ordinal,
p.proargnames[gs.ordinal] AS name,
CASE
WHEN p.proallargtypes IS NULL THEN p.proargtypes[gs.ordinal - 1]
ELSE p.proallargtypes[gs.ordinal]
END::regtype::text AS data_type,
COALESCE(p.proargmodes[gs.ordinal], 'i') AS mode,
COUNT(*) FILTER (WHERE COALESCE(p.proargmodes[gs.ordinal], 'i') IN ('i', 'b', 'v')) OVER (ORDER BY gs.ordinal) AS input_ordinal
FROM generate_series(1, COALESCE(array_length(p.proallargtypes, 1), p.pronargs)) AS gs(ordinal)
) arg
WHERE p.prokind = 'p'
AND n.nspname = ${schema}
AND p.proname = ${name}
ORDER BY arg.ordinal;`.trim();
}
if (options.databaseType === "mysql" || options.databaseType === "doris" || options.databaseType === "starrocks") {
return `
SELECT
PARAMETER_NAME AS name,
DTD_IDENTIFIER AS data_type,
COALESCE(PARAMETER_MODE, 'IN') AS mode,
ORDINAL_POSITION AS ordinal,
FALSE AS has_default
FROM information_schema.PARAMETERS
WHERE SPECIFIC_SCHEMA = ${schema}
AND SPECIFIC_NAME = ${name}
AND ORDINAL_POSITION > 0
ORDER BY ORDINAL_POSITION;`.trim();
}
if (options.databaseType === "databend") {
return `
SELECT arguments
FROM system.procedures
WHERE name = ${name}
ORDER BY procedure_id
LIMIT 1;`.trim();
}
if (options.databaseType === "sqlserver") {
return `
SELECT
p.name AS name,
TYPE_NAME(p.user_type_id) AS data_type,
CASE WHEN p.is_output = 1 THEN 'OUT' ELSE 'IN' END AS mode,
p.parameter_id AS ordinal,
p.has_default_value AS has_default
FROM sys.parameters p
JOIN sys.objects o ON o.object_id = p.object_id
JOIN sys.schemas s ON s.schema_id = o.schema_id
WHERE o.type IN ('P', 'PC')
AND s.name = ${schema}
AND o.name = ${name}
ORDER BY p.parameter_id;`.trim();
}
if (options.databaseType === "oracle" || options.databaseType === "dameng" || options.databaseType === "oceanbase-oracle") {
return `
SELECT
ARGUMENT_NAME AS name,
DATA_TYPE AS data_type,
IN_OUT AS mode,
POSITION AS ordinal,
DEFAULTED AS has_default
FROM ALL_ARGUMENTS
WHERE OWNER = UPPER(${schema})
AND OBJECT_NAME = UPPER(${name})
AND POSITION > 0
ORDER BY SEQUENCE;`.trim();
}
return null;
}
export function routineParametersFromResult(result: QueryResult, databaseType?: DatabaseType): RoutineParameter[] {
if (databaseType === "databend") return databendRoutineParametersFromResult(result);
return result.rows
.map((row, index) => ({
name: String(row[0] || `arg${index + 1}`),
dataType: String(row[1] || ""),
mode: normalizeParameterMode(row[2]),
ordinal: Number(row[3] || index + 1),
hasDefault: normalizeBoolean(row[4]),
}))
.filter((parameter) => parameter.mode !== "RETURN");
}
function databendRoutineParametersFromResult(result: QueryResult): RoutineParameter[] {
const argumentsIndex = result.columns.findIndex((column) => column.toLowerCase() === "arguments");
const signature = String(result.rows[0]?.[argumentsIndex >= 0 ? argumentsIndex : 0] || "");
const inputTypes = databendInputTypesFromArguments(signature);
return inputTypes.map((dataType, index) => ({
name: `arg${index + 1}`,
dataType,
mode: "IN",
ordinal: index + 1,
hasDefault: false,
}));
}
function databendInputTypesFromArguments(signature: string): string[] {
const openIndex = signature.indexOf("(");
if (openIndex < 0) return [];
let depth = 0;
for (let index = openIndex; index < signature.length; index += 1) {
const char = signature[index];
if (char === "(") depth += 1;
if (char === ")") {
depth -= 1;
if (depth === 0) {
return splitTopLevelComma(signature.slice(openIndex + 1, index)).filter(Boolean);
}
}
}
return [];
}
function splitTopLevelComma(value: string): string[] {
const parts: string[] = [];
let current = "";
let depth = 0;
for (const char of value) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (char === "," && depth === 0) {
parts.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) parts.push(current.trim());
return parts;
}
function normalizeParameterMode(value: unknown): RoutineParameterMode {
const mode = String(value || "IN")
.toUpperCase()
.replace(/\s+/g, "");
if (mode === "IN") return "IN";
if (mode === "OUT") return "OUT";
if (mode === "INOUT" || mode === "IN/OUT") return "INOUT";
if (mode === "RETURN") return "RETURN";
return "UNKNOWN";
}
function normalizeBoolean(value: unknown): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
const text = String(value || "").toLowerCase();
return text === "true" || text === "yes" || text === "y" || text === "1";
}
function quoteSqlLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
@@ -0,0 +1,66 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
export type PasteTableMode = "structure-and-data" | "structure-only" | "data-only";
export interface TableClipboardContext {
connectionId: string;
database: string;
schema?: string | null;
}
export interface TableDataCopyColumnOptions {
columns: string[];
postgresOverridingSystemValue: boolean;
sqlserverIdentityInsert: boolean;
}
function normalizeSchema(schema: string | null | undefined): string {
return schema?.trim() ?? "";
}
export function tableClipboardEntryMatchesTarget(entry: TableClipboardContext, target: TableClipboardContext): boolean {
return entry.connectionId === target.connectionId && entry.database === target.database && normalizeSchema(entry.schema) === normalizeSchema(target.schema);
}
export function tableClipboardMatchesTarget(entries: TableClipboardContext[], target: TableClipboardContext | null): boolean {
return !!target && entries.length > 0 && entries.every((entry) => tableClipboardEntryMatchesTarget(entry, target));
}
export function supportsWholeRowTableDataCopy(databaseType: DatabaseType | undefined): boolean {
return !!databaseType;
}
export function defaultPasteTableMode(databaseType: DatabaseType | undefined): PasteTableMode {
return supportsWholeRowTableDataCopy(databaseType) ? "structure-and-data" : "structure-only";
}
export function pasteTableModeCopiesData(mode: PasteTableMode): boolean {
return mode === "structure-and-data" || mode === "data-only";
}
export function tableDataCopyColumnOptions(databaseType: DatabaseType | undefined, columns: ColumnInfo[]): TableDataCopyColumnOptions {
const writableColumns = columns.filter((column) => isWritableTableDataCopyColumn(databaseType, column));
return {
columns: writableColumns.map((column) => column.name),
postgresOverridingSystemValue: databaseType === "postgres" && writableColumns.some(isIdentityColumn),
sqlserverIdentityInsert: databaseType === "sqlserver" && writableColumns.some(isIdentityColumn),
};
}
function isWritableTableDataCopyColumn(databaseType: DatabaseType | undefined, column: ColumnInfo): boolean {
const extra = (column.extra ?? "").toLowerCase();
if (databaseType === "mysql") {
return !extra.includes("generated");
}
if (databaseType === "postgres") {
return !extra.includes("generated always as (");
}
if (databaseType === "sqlserver") {
return !extra.includes("computed");
}
return !extra.includes("computed") && !(extra.includes("generated") && !extra.includes("identity"));
}
function isIdentityColumn(column: ColumnInfo): boolean {
return (column.extra ?? "").toLowerCase().includes("identity");
}
@@ -0,0 +1,130 @@
import type { DatabaseType } from "@/types/database";
import type { EditableStructureColumn } from "@/lib/table/tableStructureEditorSql";
import { manifestDatabaseTypes } from "@/lib/database/databaseDriverManifest";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
export interface TableColumnTemplate {
id: string;
labelKey: string;
columnNames: string[];
}
export interface TableColumnTemplateField {
name: string;
dataTypesByDatabase: Partial<Record<DatabaseType, string>>;
defaultValue?: string;
isNullable?: boolean;
comment?: string;
}
export const PRESET_FIELDS_TEMPLATE_ID = "preset-fields";
export const EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE = "<empty>";
export const TABLE_COLUMN_TEMPLATE_DATABASE_TYPES: DatabaseType[] = manifestDatabaseTypes().filter(isTableColumnTemplateDatabaseType);
export const DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS: string[] = [];
export function normalizeTableColumnTemplateFields(value: unknown): string[] {
if (!Array.isArray(value)) return [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS];
const fields: string[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") continue;
const field = item.trim();
const name = field.split("|")[0]?.trim();
if (!field || !name || seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
fields.push(field);
}
return fields;
}
export function parseTableColumnTemplateFields(value: unknown): TableColumnTemplateField[] {
return normalizeTableColumnTemplateFields(value).map(parseTableColumnTemplateField);
}
export function tableColumnTemplates(columnNames: readonly string[] = DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS): TableColumnTemplate[] {
const fields = parseTableColumnTemplateFields([...columnNames]);
return [
{
id: PRESET_FIELDS_TEMPLATE_ID,
labelKey: "structureEditor.presetFieldsTemplate",
columnNames: fields.map((field) => field.name),
},
];
}
export function createTableColumnTemplateDrafts(options: { templateId: string; databaseType?: DatabaseType; columnNames?: readonly string[]; existingColumnNames?: Iterable<string>; createId: () => string }): EditableStructureColumn[] {
if (options.templateId !== PRESET_FIELDS_TEMPLATE_ID) return [];
const existingNames = new Set([...(options.existingColumnNames ?? [])].map((name) => name.toLowerCase()));
return presetFieldColumns(options.databaseType, parseTableColumnTemplateFields([...(options.columnNames ?? DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS)]), options.createId).filter((column) => !existingNames.has(column.name.toLowerCase()));
}
function presetFieldColumns(databaseType: DatabaseType | undefined, fields: readonly TableColumnTemplateField[], createId: () => string): EditableStructureColumn[] {
return fields
.filter((field) => isTableColumnTemplateFieldApplicable(field, databaseType))
.map((field) => {
return templateColumn(createId, field.name, configuredFieldDataType(field, databaseType) ?? "", field.isNullable ?? false, field.defaultValue ?? "", field.comment ?? "");
});
}
function templateColumn(createId: () => string, name: string, dataType: string, isNullable: boolean, defaultValue = "", comment = ""): EditableStructureColumn {
return {
id: `new:${createId()}`,
name,
dataType,
isNullable,
defaultValue,
comment,
isPrimaryKey: false,
extra: {},
markedForDrop: false,
};
}
function parseTableColumnTemplateField(value: string): TableColumnTemplateField {
const [rawName = "", ...rawParts] = value.split("|").map((part) => part.trim());
const field: TableColumnTemplateField = { name: rawName, dataTypesByDatabase: {} };
for (const part of rawParts) {
const separator = part.indexOf(":");
if (separator <= 0) continue;
const key = part.slice(0, separator).trim().toLowerCase();
const dataType = part.slice(separator + 1).trim();
if (!dataType) continue;
if (key === "nullable") {
field.isNullable = parseBooleanConfigValue(dataType);
} else if (key === "required") {
const required = parseBooleanConfigValue(dataType);
field.isNullable = required === undefined ? undefined : !required;
} else if (key === "default" || key === "defaultvalue" || key === "default_value") {
field.defaultValue = dataType;
} else if (key === "comment" || key === "description") {
field.comment = dataType;
} else if (isDatabaseTypeKey(key)) {
field.dataTypesByDatabase[key] = dataType;
}
}
return field;
}
function parseBooleanConfigValue(value: string): boolean | undefined {
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "yes" || normalized === "1") return true;
if (normalized === "false" || normalized === "no" || normalized === "0") return false;
return undefined;
}
function configuredFieldDataType(field: TableColumnTemplateField, databaseType: DatabaseType | undefined): string | undefined {
const configuredDataType = databaseType ? field.dataTypesByDatabase[databaseType] : undefined;
return configuredDataType && configuredDataType !== EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE ? configuredDataType : undefined;
}
function isTableColumnTemplateFieldApplicable(field: TableColumnTemplateField, databaseType: DatabaseType | undefined): boolean {
return !!databaseType && Object.prototype.hasOwnProperty.call(field.dataTypesByDatabase, databaseType);
}
function isTableColumnTemplateDatabaseType(databaseType: DatabaseType): boolean {
return databaseType !== "manticoresearch" && getTableStructureCapabilities(databaseType).createTable;
}
function isDatabaseTypeKey(value: string): value is DatabaseType {
return TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes(value as DatabaseType);
}
@@ -0,0 +1,53 @@
import type { DatabaseType, QueryResult } from "@/types/database.ts";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql.ts";
export const TABLE_DATA_EXPORT_PAGE_SIZE = 10_000;
export interface FetchTableDataForExportOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
tableType?: string;
columns?: string[];
pageSize?: number;
buildPageSql?: (options: { databaseType?: DatabaseType; schema?: string; tableName: string; tableType?: string; columns?: string[]; limit: number; offset: number }) => Promise<string> | string;
executePage: (sql: string) => Promise<QueryResult>;
}
export async function fetchTableDataForExport(options: FetchTableDataForExportOptions): Promise<QueryResult> {
const pageSize = Math.max(1, options.pageSize ?? TABLE_DATA_EXPORT_PAGE_SIZE);
let offset = 0;
const rows: QueryResult["rows"] = [];
let columns: string[] = [];
let executionTimeMs = 0;
while (true) {
const sql = await (options.buildPageSql ?? buildTableSelectSql)({
databaseType: options.databaseType,
schema: options.schema,
tableName: options.tableName,
tableType: options.tableType,
columns: options.columns,
limit: pageSize,
offset,
});
const result = await options.executePage(sql);
if (columns.length === 0) columns = result.columns;
rows.push(...result.rows);
executionTimeMs += result.execution_time_ms ?? 0;
if (result.rows.length < pageSize) {
return {
columns,
rows,
affected_rows: 0,
execution_time_ms: executionTimeMs,
truncated: false,
session_id: undefined,
has_more: false,
};
}
offset += result.rows.length;
}
}
@@ -0,0 +1,28 @@
import type { QueryTab, ColumnInfo } from "@/types/database";
export type DataTabTableMeta = NonNullable<QueryTab["tableMeta"]>;
function fallbackColumnInfo(name: string): ColumnInfo {
return {
name,
data_type: "",
is_nullable: true,
column_default: null,
is_primary_key: false,
extra: null,
};
}
export function tableMetaForDataTab(tab: QueryTab | undefined): DataTabTableMeta | undefined {
if (!tab || tab.mode !== "data") return undefined;
if (tab.tableMeta) return tab.tableMeta;
const tableName = tab.title.trim();
if (!tableName) return undefined;
return {
schema: tab.schema,
tableName,
columns: (tab.result?.columns ?? []).map(fallbackColumnInfo),
primaryKeys: [],
};
}
@@ -0,0 +1,73 @@
import type { ForeignKeyInfo } from "@/types/database";
export interface TableWithFk {
name: string;
schema?: string | null;
foreignKeys: ForeignKeyInfo[];
}
/**
* Sort tables by foreign key dependency so that referencing tables
* (those with an FK pointing to another table in the list) come before
* the tables they reference.
*
* Uses Kahn's algorithm for topological sort. Falls back to original
* order when a cycle is detected.
*/
export function sortTablesByFkDependency(tables: TableWithFk[]): TableWithFk[] {
if (tables.length <= 1) return tables;
const nameSet = new Set(tables.map((t) => t.name));
const adjacency = new Map<string, string[]>();
const inDegree = new Map<string, number>();
for (const table of tables) {
if (!adjacency.has(table.name)) adjacency.set(table.name, []);
if (!inDegree.has(table.name)) inDegree.set(table.name, 0);
}
// Build dependency graph: an edge A → B means A references B,
// so A should be dropped before B.
for (const table of tables) {
for (const fk of table.foreignKeys) {
if (nameSet.has(fk.ref_table)) {
// A → B: A depends on B, drop A first
adjacency.get(table.name)!.push(fk.ref_table);
inDegree.set(fk.ref_table, (inDegree.get(fk.ref_table) ?? 0) + 1);
}
}
}
// Kahn topological sort
const queue: string[] = [];
for (const [name, degree] of inDegree) {
if (degree === 0) queue.push(name);
}
const sortedNames: string[] = [];
while (queue.length > 0) {
// Sort queue for deterministic output; preserves original order
// among nodes with equal in-degree.
queue.sort((a, b) => {
const ia = tables.findIndex((t) => t.name === a);
const ib = tables.findIndex((t) => t.name === b);
return ia - ib;
});
const current = queue.shift()!;
sortedNames.push(current);
for (const neighbor of adjacency.get(current) ?? []) {
const newDegree = (inDegree.get(neighbor) ?? 1) - 1;
inDegree.set(neighbor, newDegree);
if (newDegree === 0) queue.push(neighbor);
}
}
// If not all nodes sorted, cycle detected — fall back to original order
if (sortedNames.length !== tables.length) {
return tables;
}
const nameToTable = new Map(tables.map((t) => [t.name, t]));
return sortedNames.map((name) => nameToTable.get(name)!);
}
@@ -0,0 +1,96 @@
import type { ColumnInfo, DatabaseType, IndexInfo } from "@/types/database";
import { getDatabaseCapability } from "@/lib/database/databaseCapabilities";
export const DBX_ROWID_COLUMN = "__DBX_ROWID";
export const DBX_NEO4J_ELEMENT_ID_COLUMN = "__DBX_ELEMENT_ID";
export const DBX_TDENGINE_TBNAME_COLUMN = "tbname";
function isViewTableType(tableType?: string): boolean {
return tableType?.toUpperCase().includes("VIEW") === true;
}
export function editablePrimaryKeys(databaseType: DatabaseType | undefined, columns: ColumnInfo[], tableType?: string): string[] {
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (isViewTableType(tableType)) return primaryKeys;
if (databaseType === "tdengine" && primaryKeys.length > 0) return [DBX_TDENGINE_TBNAME_COLUMN, ...primaryKeys];
const syntheticKey = getDatabaseCapability(databaseType).syntheticKey;
if (syntheticKey === "oracle-rowid" && primaryKeys.length === 0) return [DBX_ROWID_COLUMN];
if (syntheticKey === "neo4j-element-id" && primaryKeys.length === 0) return [DBX_NEO4J_ELEMENT_ID_COLUMN];
return primaryKeys;
}
export function editableRowIdentifierColumns(databaseType: DatabaseType | undefined, columns: ColumnInfo[], indexes?: IndexInfo[], tableType?: string): string[] {
const primaryKeys = editablePrimaryKeys(databaseType, columns, tableType);
if (primaryKeys.length > 0) return primaryKeys;
const uniqueIndex = indexes?.filter((index) => !index.filter && index.columns.length > 0 && (index.is_primary || index.is_unique)).sort((left, right) => Number(right.is_primary) - Number(left.is_primary) || left.columns.length - right.columns.length)[0];
return uniqueIndex?.columns ?? [];
}
export function isTableDataEditable(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean {
if (isViewTableType(tableType)) return false;
const cap = getDatabaseCapability(databaseType).tableData;
if (cap.readonly) return false;
if (cap.insert) return true;
return primaryKeys.length > 0;
}
export function canInsertTableRows(databaseType: DatabaseType | undefined): boolean {
return getDatabaseCapability(databaseType).tableData.insert;
}
export function supportsDataGridTransaction(databaseType: DatabaseType | undefined): boolean {
return getDatabaseCapability(databaseType).tableData.transaction;
}
export function usesKeylessRowPredicate(databaseType: DatabaseType | undefined): boolean {
return !!getDatabaseCapability(databaseType).tableData.keylessRowPredicate;
}
export function canUseKeylessRowPredicate(databaseType: DatabaseType | undefined, primaryKeys: readonly string[]): boolean {
return primaryKeys.length === 0 && usesKeylessRowPredicate(databaseType);
}
export function canEditExistingTableRows(databaseType: DatabaseType | undefined, hiveTableTransactional?: boolean, primaryKeys?: string[]): boolean {
const tableData = getDatabaseCapability(databaseType).tableData;
if (tableData.readonly) return false;
if (tableData.existingRowsReadonly) return false;
if (tableData.requiresTransactionalTableForExistingRows && hiveTableTransactional !== true) return false;
if (databaseType === "tdengine" && !primaryKeys?.some((key) => key.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN)) return false;
if (tableData.updateRequiresPrimaryKey && primaryKeys && primaryKeys.length === 0) return false;
return true;
}
export function hiveTablePropertiesIndicateTransactional(result: { rows: readonly (readonly unknown[])[] }): boolean {
return result.rows.some((row) => {
const name = String(row[0] ?? "")
.trim()
.toLowerCase();
const value = String(row[1] ?? "")
.trim()
.toLowerCase();
return name === "transactional" && value === "true";
});
}
export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, primaryKeys: string[]): boolean {
return primaryKeys.length === 1 && ((databaseType === "oracle" && primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN) || (databaseType === "neo4j" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN));
}
export function isHiddenGridColumn(databaseType: DatabaseType | undefined, column: string, primaryKeys: string[]): boolean {
if (databaseType === "neo4j" && column === DBX_NEO4J_ELEMENT_ID_COLUMN) return true;
return usesSyntheticRowIdKey(databaseType, primaryKeys) && column.toUpperCase() === DBX_ROWID_COLUMN;
}
export function isTdengineExistingRowReadonlyColumn(databaseType: DatabaseType | undefined, column: string, columns: ColumnInfo[]): boolean {
if (databaseType !== "tdengine") return false;
if (column.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN) return true;
const columnInfo = columns.find((info) => info.name.toLowerCase() === column.toLowerCase());
return !!columnInfo?.is_primary_key || /\btag\b/i.test(columnInfo?.extra ?? "");
}
export function isClickHouseExistingRowReadonlyColumn(databaseType: DatabaseType | undefined, column: string, primaryKeys: readonly string[], columns: ColumnInfo[] = []): boolean {
if (databaseType !== "clickhouse") return false;
if (primaryKeys.some((key) => key.toLowerCase() === column.toLowerCase())) return true;
const columnInfo = columns.find((info) => info.name.toLowerCase() === column.toLowerCase());
return /\bpartition_key\b/i.test(columnInfo?.extra ?? "");
}
+12
View File
@@ -0,0 +1,12 @@
export const IMPORT_SKIP_TARGET = "";
export function normalizeImportColumnName(name: string): string {
return name.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
}
export function autoMapImportColumns(sourceColumns: string[], targetColumns: string[]): Record<string, string> {
const exactTargets = new Map(targetColumns.map((column) => [column, column]));
const normalizedTargets = new Map(targetColumns.map((column) => [normalizeImportColumnName(column), column]));
return Object.fromEntries(sourceColumns.map((source) => [source, exactTargets.get(source) ?? normalizedTargets.get(normalizeImportColumnName(source)) ?? IMPORT_SKIP_TARGET]));
}
@@ -0,0 +1,73 @@
import type { DatabaseType } from "@/types/database";
export interface TableMetadataCapabilities {
columns: boolean;
indexes: boolean;
foreignKeys: boolean;
triggers: boolean;
ddl: boolean;
}
const defaultCapabilities: TableMetadataCapabilities = {
columns: true,
indexes: true,
foreignKeys: true,
triggers: true,
ddl: true,
};
const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabilities>>> = {
clickhouse: {
foreignKeys: false,
triggers: false,
},
manticoresearch: {
foreignKeys: false,
triggers: false,
},
elasticsearch: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
qdrant: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
milvus: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
weaviate: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
chromadb: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
influxdb: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
questdb: {
indexes: true,
foreignKeys: false,
triggers: false,
},
};
export function getTableMetadataCapabilities(dbType?: DatabaseType): TableMetadataCapabilities {
return { ...defaultCapabilities, ...(dbType ? capabilityByType[dbType] : undefined) };
}
@@ -0,0 +1,8 @@
import { DEFAULT_RESULT_PAGE_SIZE, normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
export const DEFAULT_TABLE_OPEN_PAGE_LIMIT = DEFAULT_RESULT_PAGE_SIZE;
export function tableOpenPageLimit(preferredLimit?: unknown): number {
// Data tabs should reopen with the rows-per-page preference the grid persists.
return normalizeResultPageSize(preferredLimit, DEFAULT_TABLE_OPEN_PAGE_LIMIT);
}
@@ -0,0 +1,63 @@
import type { DatabaseType } from "@/types/database.ts";
import { isSchemaAware, usesDatabaseObjectTreeMode } from "@/lib/database/databaseCapabilities.ts";
import * as api from "@/lib/backend/api.ts";
import { parseSqlServerLinkedSchema, sqlServerLinkedTableName } from "@/lib/database/sqlServerLinkedServers.ts";
export interface BuildTableSelectSqlOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
tableType?: string;
primaryKeys?: string[];
columns?: string[];
fallbackOrderColumns?: string[];
orderBy?: string;
limit?: number;
offset?: number;
whereInput?: string;
includeRowId?: boolean;
}
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
if (databaseType === "iotdb") return name;
// JDBC connections use the driver-reported identifier quote string
// (DatabaseMetaData.getIdentifierQuoteString()) — pass through unquoted.
if (databaseType === "jdbc") return name;
if (databaseType === "mysql" || databaseType === "clickhouse" || databaseType === "hive" || databaseType === "databend" || databaseType === "tdengine" || databaseType === "access") return `\`${name.replace(/`/g, "``")}\``;
if (databaseType === "informix" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(name)) return name;
if (databaseType === "neo4j") return quoteCypherIdentifier(name);
if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`;
return `"${name.replace(/"/g, '""')}"`;
}
function quoteCypherIdentifier(name: string): string {
return `\`${name.replace(/`/g, "``")}\``;
}
export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName">): string {
const { databaseType, schema, tableName } = options;
if (databaseType === "iotdb") {
const trimmedSchema = schema?.trim();
if (trimmedSchema && tableName !== trimmedSchema && !tableName.startsWith(`${trimmedSchema}.`)) {
return `${quoteTableIdentifier(databaseType, trimmedSchema)}.${quoteTableIdentifier(databaseType, tableName)}`;
}
return quoteTableIdentifier(databaseType, tableName);
}
if (isSchemaAware(databaseType) && !usesDatabaseObjectTreeMode(databaseType) && schema) {
if (databaseType === "sqlserver") {
const linked = parseSqlServerLinkedSchema(schema);
if (linked) return sqlServerLinkedTableName(linked, tableName);
}
return `${quoteTableIdentifier(databaseType, schema)}.${quoteTableIdentifier(databaseType, tableName)}`;
}
return quoteTableIdentifier(databaseType, tableName);
}
export function normalizeWhereInput(whereInput?: string): string {
const withoutSemicolon = whereInput?.trim().replace(/;+$/, "").trim() ?? "";
return withoutSemicolon.replace(/^where\b/i, "").trim();
}
export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise<string> {
return api.buildTableSelectSql(options);
}
@@ -0,0 +1,196 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
export interface TableSqlTemplateOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
columns?: ColumnInfo[];
tableType?: string;
}
export function buildTableSelectTemplate(options: TableSqlTemplateOptions): string {
const tableName = templateTableName(options);
const columns = options.columns ?? [];
if (!columns.length) {
return `SELECT *\nFROM ${tableName};`;
}
const selectColumns = columns.map((column) => quoteTableIdentifier(options.databaseType, column.name));
return `SELECT ${selectColumns.join(", ")}\nFROM ${tableName};`;
}
export function buildTableInsertTemplate(options: TableSqlTemplateOptions): string {
const strategy = options.databaseType ? insertTemplateStrategies[options.databaseType] : undefined;
return (strategy ?? buildDefaultInsertTemplate)(options);
}
type InsertTemplateStrategy = (options: TableSqlTemplateOptions) => string;
function buildDefaultInsertTemplate(options: TableSqlTemplateOptions): string {
const tableName = templateTableName(options);
const columns = (options.columns ?? []).filter((column) => !isAutoGeneratedColumn(column));
// TDengine does not support INSERT INTO t(col1, col2) VALUES(...) syntax
if (options.databaseType === "tdengine") {
const values = columns.map((column) => columnPlaceholderValue(column, options.databaseType));
if (!values.length) {
return `INSERT INTO ${tableName}\nVALUES (/* TODO: add values */);`;
}
return `INSERT INTO ${tableName}\nVALUES (${values.join(", ")});`;
}
if (!columns.length) {
return `INSERT INTO ${tableName}\n/* TODO: add column values */\nVALUES ();`;
}
const columnNames = columns.map((column) => quoteTableIdentifier(options.databaseType, column.name));
const values = columns.map((column) => columnPlaceholderValue(column, options.databaseType));
return `INSERT INTO ${tableName} (${columnNames.join(", ")})\nVALUES (${values.join(", ")});`;
}
function buildTdengineInsertTemplate(options: TableSqlTemplateOptions): string {
const columns = options.columns ?? [];
const isTagColumn = (column: ColumnInfo) => (column.comment ?? "").toUpperCase() === "TAG" || (column.extra ?? "").toUpperCase().includes("TAG");
const tagColumns = columns.filter((column) => isTagColumn(column));
const normalizedTableType = (options.tableType ?? "").toUpperCase();
const shouldUseStableTemplate = normalizedTableType === "STABLE" || (!normalizedTableType && tagColumns.length > 0);
if (!shouldUseStableTemplate) {
return buildDefaultInsertTemplate(options);
}
const valueColumns = columns.filter((column) => !isTagColumn(column)).filter((column) => !isAutoGeneratedColumn(column));
const tagPlaceholders = tagColumns.map((column) => columnPlaceholderValue(column, options.databaseType));
const tagsSql = tagPlaceholders.length > 0 ? `TAGS (${tagPlaceholders.join(", ")})` : "TAGS (/* TODO: add tag values */)";
const stableNameForUsing = templateTableName(options).replace(/^`|`$/g, "");
const valuePlaceholders = valueColumns.map((column) => columnPlaceholderValue(column, options.databaseType));
if (!valueColumns.length) {
return `INSERT INTO /* child_table_name */ USING ${stableNameForUsing}\n${tagsSql}\nVALUES (/* TODO: add values */);`;
}
return `INSERT INTO /* child_table_name */ USING ${stableNameForUsing}\n${tagsSql}\nVALUES (${valuePlaceholders.join(", ")});`;
}
const insertTemplateStrategies: Partial<Record<DatabaseType, InsertTemplateStrategy>> = {
tdengine: buildTdengineInsertTemplate,
};
export function buildTableUpdateTemplate(options: TableSqlTemplateOptions): string {
const tableName = templateTableName(options);
const columns = options.columns ?? [];
const primaryKeys = columns.filter((column) => column.is_primary_key);
const primaryKeyNames = new Set(primaryKeys.map((column) => column.name));
const updateColumns = columns.filter((column) => !primaryKeyNames.has(column.name));
const setClause = updateColumns.length ? updateColumns.map((column) => `${quoteTableIdentifier(options.databaseType, column.name)} = ${columnPlaceholderValue(column, options.databaseType)}`).join(",\n ") : "/* TODO: set column = value */";
return `UPDATE ${tableName}\nSET ${setClause}\n${buildWhereClause(options.databaseType, primaryKeys)};`;
}
export function buildTableDeleteTemplate(options: TableSqlTemplateOptions): string {
const tableName = templateTableName(options);
const primaryKeys = (options.columns ?? []).filter((column) => column.is_primary_key);
return `DELETE FROM ${tableName}\n${buildWhereClause(options.databaseType, primaryKeys)};`;
}
function templateTableName(options: TableSqlTemplateOptions): string {
return qualifiedTableName({
databaseType: options.databaseType,
schema: options.schema,
tableName: options.tableName,
});
}
function buildWhereClause(databaseType: DatabaseType | undefined, primaryKeys: ColumnInfo[]): string {
if (!primaryKeys.length) return "WHERE /* TODO: add WHERE clause */";
const conditions = primaryKeys.map((column) => `${quoteTableIdentifier(databaseType, column.name)} = ${columnPlaceholderValue(column, databaseType)}`);
return `WHERE ${conditions.join(" AND ")}`;
}
function isAutoGeneratedColumn(column: ColumnInfo): boolean {
const extra = (column.extra ?? "").toLowerCase().trim();
const colDefault = (column.column_default ?? "").toLowerCase().trim();
const dataType = (column.data_type ?? "").trim();
if (typeLooksPostgresTextSearchVector(dataType)) return true;
if (extra.includes("auto_increment")) return true;
if (extra.includes("autoincrement")) return true;
if (extra.includes("identity")) return true;
if (extra.includes("generated") && (extra.includes("as identity") || extra.includes("always as"))) return true;
if (colDefault.includes("nextval(")) return true;
if (colDefault.includes(".nextval")) return true;
if ((extra.includes("virtual") || extra.includes("computed")) && extra.includes("generated")) return true;
return false;
}
function columnPlaceholderValue(column: ColumnInfo, databaseType?: DatabaseType): string {
const dataType = (column.data_type ?? "").trim();
const colName = column.name;
if (typeLooksNumeric(dataType)) return "0";
if (typeLooksBoolean(dataType)) return databaseType === "mysql" || databaseType === "sqlite" || databaseType === "rqlite" ? "1" : "TRUE";
if (typeLooksDateOnly(dataType)) return "'2024-01-01'";
if (typeLooksTimeOnly(dataType)) return "'12:00:00'";
if (typeLooksTimestamp(dataType)) return databaseType === "tdengine" ? "NOW" : "CURRENT_TIMESTAMP";
if (typeLooksJson(dataType)) return databaseType === "postgres" || databaseType === "redshift" ? "'{}'::jsonb" : "'{}'";
if (typeLooksBinary(dataType)) return "''";
if (typeLooksUuid(dataType)) return databaseType === "postgres" || databaseType === "redshift" ? "'00000000-0000-0000-0000-000000000000'::uuid" : "'00000000-0000-0000-0000-000000000000'";
if (typeLooksXml(dataType)) return "'<root/>'";
if (typeLooksSpatial(dataType)) return "NULL";
if (typeLooksArray(dataType)) return "'{}'";
return `'${colName}_value'`;
}
function typeLooksNumeric(dataType: string): boolean {
return /^(tinyint|smallint|mediumint|int\d*|integer\d*|bigint|smallserial|serial\d*|bigserial|number|numeric|decimal|dec|float\d*|double|real|double\s+precision|money|smallmoney|fixed)/i.test(dataType.trim());
}
function typeLooksBoolean(dataType: string): boolean {
return /^(bool|boolean|bit\s*\(\s*1\s*\))$/i.test(dataType.trim());
}
function typeLooksDateOnly(dataType: string): boolean {
return /^date$/i.test(dataType.trim());
}
function typeLooksTimeOnly(dataType: string): boolean {
return /^time\b(?!stamp|stamptz|with|without)/i.test(dataType.trim());
}
function typeLooksTimestamp(dataType: string): boolean {
return /^(datetime|timestamp|timestamptz|timestamp\s+with\s+time\s+zone|timestamp\s+without\s+time\s+zone|smalldatetime|datetime2|datetimeoffset)/i.test(dataType.trim());
}
function typeLooksJson(dataType: string): boolean {
return /^(json|jsonb)$/i.test(dataType.trim());
}
function typeLooksBinary(dataType: string): boolean {
return /^(bytea|blob|binary|varbinary|image|raw|longblob|mediumblob|tinyblob|long\s+raw|bfile)$/i.test(dataType.trim());
}
function typeLooksUuid(dataType: string): boolean {
return /^uuid$/i.test(dataType.trim());
}
function typeLooksXml(dataType: string): boolean {
return /^xml$/i.test(dataType.trim());
}
function typeLooksSpatial(dataType: string): boolean {
return /^(geometry|geography|point|polygon|linestring|box|circle|path|line|lseg)/i.test(dataType.trim());
}
function typeLooksArray(dataType: string): boolean {
return /^(array|vector|_float\d*|_int\d*|_text|_varchar|_bool|_uuid)/i.test(dataType.trim());
}
function typeLooksPostgresTextSearchVector(dataType: string): boolean {
const normalized = dataType
.trim()
.replace(/^"+|"+$/g, "")
.toLowerCase();
return normalized === "tsvector" || normalized.endsWith(".tsvector");
}
@@ -0,0 +1,330 @@
import type { DatabaseType } from "@/types/database";
export type TableStructureDialect = "mysql" | "postgres" | "sqlite" | "duckdb" | "sqlserver" | "oracle" | "h2" | "clickhouse" | "informix" | "influxdb" | "unsupported";
export interface TableStructureCapabilities {
dialect: TableStructureDialect;
createTable: boolean;
addColumn: boolean;
dropColumn: boolean;
renameColumn: boolean;
alterExistingColumn: boolean;
alterType: boolean;
alterNullability: boolean;
alterDefault: boolean;
alterPrimaryKey: boolean;
reorderColumn: boolean;
comment: boolean;
createIndex: boolean;
dropIndex: boolean;
rebuildIndex: boolean;
indexType: boolean;
indexInclude: boolean;
indexFilter: boolean;
indexComment: boolean;
foreignKey: boolean;
}
const unsupportedCapabilities: TableStructureCapabilities = {
dialect: "unsupported",
createTable: false,
addColumn: false,
dropColumn: false,
renameColumn: false,
alterExistingColumn: false,
alterType: false,
alterNullability: false,
alterDefault: false,
alterPrimaryKey: false,
reorderColumn: false,
comment: false,
createIndex: false,
dropIndex: false,
rebuildIndex: false,
indexType: false,
indexInclude: false,
indexFilter: false,
indexComment: false,
foreignKey: false,
};
function capabilities(overrides: Partial<TableStructureCapabilities>): TableStructureCapabilities {
return { ...unsupportedCapabilities, ...overrides };
}
const mysqlCapabilities = capabilities({
dialect: "mysql",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
reorderColumn: true,
comment: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
indexType: true,
indexComment: true,
alterPrimaryKey: true,
foreignKey: true,
});
const gbaseCapabilities = capabilities({
dialect: "mysql",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
reorderColumn: true,
});
const postgresCapabilities = capabilities({
dialect: "postgres",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
comment: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
indexType: true,
indexInclude: true,
indexFilter: true,
indexComment: true,
alterPrimaryKey: true,
foreignKey: true,
});
const redshiftCapabilities = capabilities({
...postgresCapabilities,
createIndex: false,
dropIndex: false,
rebuildIndex: false,
indexType: false,
indexInclude: false,
indexFilter: false,
indexComment: false,
alterPrimaryKey: false,
});
const sqliteCapabilities = capabilities({
dialect: "sqlite",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
indexFilter: true,
});
const duckdbCapabilities = capabilities({
dialect: "duckdb",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
});
const sqlserverCapabilities = capabilities({
dialect: "sqlserver",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
comment: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
indexType: true,
indexInclude: true,
indexFilter: true,
indexComment: true,
});
const oracleCapabilities = capabilities({
dialect: "oracle",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
comment: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
indexType: true,
});
const h2Capabilities = capabilities({
dialect: "h2",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
comment: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
});
const clickhouseCapabilities = capabilities({
dialect: "clickhouse",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
reorderColumn: true,
comment: true,
});
const informixCapabilities = capabilities({
dialect: "informix",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
createIndex: true,
dropIndex: true,
rebuildIndex: true,
});
const accessCapabilities = capabilities({
dialect: "h2",
createTable: true,
addColumn: true,
createIndex: true,
});
const influxdbCapabilities = capabilities({
dialect: "influxdb",
createTable: false,
addColumn: false,
dropColumn: false,
renameColumn: false,
alterExistingColumn: false,
alterType: false,
alterNullability: false,
alterDefault: false,
reorderColumn: false,
comment: false,
});
const manticoreSearchCapabilities = capabilities({
dialect: "mysql",
createTable: true,
addColumn: true,
dropColumn: true,
});
const questdbCapabilities = capabilities({
dialect: "postgres",
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: false,
alterDefault: false,
comment: false,
createIndex: false,
dropIndex: false,
rebuildIndex: false,
indexType: false,
indexInclude: false,
indexFilter: false,
indexComment: false,
alterPrimaryKey: false,
foreignKey: false,
});
const firebirdCapabilities = capabilities({
...postgresCapabilities,
foreignKey: false,
});
const capabilityByType: Partial<Record<DatabaseType, TableStructureCapabilities>> = {
mysql: mysqlCapabilities,
doris: mysqlCapabilities,
starrocks: mysqlCapabilities,
goldendb: mysqlCapabilities,
sundb: mysqlCapabilities,
oscar: unsupportedCapabilities,
databend: mysqlCapabilities,
gbase: gbaseCapabilities,
postgres: postgresCapabilities,
gaussdb: postgresCapabilities,
kwdb: postgresCapabilities,
opengauss: postgresCapabilities,
questdb: questdbCapabilities,
redshift: redshiftCapabilities,
vertica: redshiftCapabilities,
highgo: postgresCapabilities,
vastbase: postgresCapabilities,
kingbase: postgresCapabilities,
firebird: firebirdCapabilities,
sqlite: sqliteCapabilities,
rqlite: sqliteCapabilities,
turso: sqliteCapabilities,
duckdb: duckdbCapabilities,
sqlserver: sqlserverCapabilities,
oracle: oracleCapabilities,
dameng: oracleCapabilities,
"oceanbase-oracle": oracleCapabilities,
iris: oracleCapabilities,
yashandb: oracleCapabilities,
xugu: oracleCapabilities,
h2: h2Capabilities,
access: accessCapabilities,
clickhouse: clickhouseCapabilities,
informix: informixCapabilities,
influxdb: influxdbCapabilities,
manticoresearch: manticoreSearchCapabilities,
};
export function getTableStructureCapabilities(dbType?: DatabaseType): TableStructureCapabilities {
return dbType ? (capabilityByType[dbType] ?? unsupportedCapabilities) : unsupportedCapabilities;
}
export function canEditTableStructure(dbType?: DatabaseType): boolean {
const caps = getTableStructureCapabilities(dbType);
return caps.createTable || caps.addColumn || caps.alterExistingColumn || caps.createIndex || caps.dropIndex;
}
export function canAddTableStructureColumn(dbType: DatabaseType | undefined, isCreateMode: boolean): boolean {
const caps = getTableStructureCapabilities(dbType);
return isCreateMode ? caps.createTable : caps.addColumn;
}
@@ -0,0 +1,93 @@
import type { ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database.ts";
export interface ColumnIdentity {
generation?: "BY DEFAULT" | "ALWAYS";
seed?: number;
increment?: number;
}
export interface ColumnExtra {
autoIncrement?: boolean;
onUpdateCurrentTimestamp?: boolean;
identity?: ColumnIdentity;
manticoreIndexed?: boolean;
manticoreStored?: boolean;
manticoreAttribute?: boolean;
manticoreSecondaryIndex?: boolean;
}
export interface EditableStructureColumn {
id: string;
name: string;
dataType: string;
isNullable: boolean;
defaultValue: string;
comment: string;
isPrimaryKey: boolean;
extra: ColumnExtra;
original?: ColumnInfo;
originalPosition?: number;
markedForDrop: boolean;
}
export interface EditableStructureIndex {
id: string;
name: string;
columns: string[];
nameEdited?: boolean;
isUnique: boolean;
isPrimary: boolean;
filter: string;
indexType: string;
includedColumns: string[];
comment: string;
original?: IndexInfo;
markedForDrop: boolean;
}
export interface EditableStructureForeignKey {
id: string;
name: string;
column: string;
refSchema: string;
refTable: string;
refColumn: string;
onUpdate: string;
onDelete: string;
original?: ForeignKeyInfo;
markedForDrop: boolean;
}
export interface EditableStructureTrigger {
id: string;
name: string;
timing: string;
event: string;
statement: string;
original?: TriggerInfo;
markedForDrop: boolean;
}
export interface BuildTableStructureChangeSqlOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
columns: EditableStructureColumn[];
indexes: EditableStructureIndex[];
foreignKeys?: EditableStructureForeignKey[];
triggers?: EditableStructureTrigger[];
tableComment?: string;
originalTableComment?: string;
}
export interface TableStructureChangeSql {
statements: string[];
warnings: string[];
}
export interface BuildSingleColumnAlterSqlOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
column: EditableStructureColumn;
}
@@ -0,0 +1,922 @@
import type { ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database.ts";
import type { ColumnExtra, EditableStructureColumn, EditableStructureForeignKey, EditableStructureIndex, EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql.ts";
export const DATA_TYPE_OPTIONS: Record<string, string[]> = {
mysql: [
"tinyint",
"tinyint unsigned",
"smallint",
"smallint unsigned",
"mediumint",
"mediumint unsigned",
"int",
"int unsigned",
"integer",
"integer unsigned",
"bigint",
"bigint unsigned",
"float",
"double",
"double precision",
"real",
"decimal",
"numeric",
"bit",
"boolean",
"bool",
"serial",
"char",
"varchar",
"tinytext",
"text",
"mediumtext",
"longtext",
"binary",
"varbinary",
"tinyblob",
"blob",
"mediumblob",
"longblob",
"enum",
"set",
"date",
"datetime",
"timestamp",
"time",
"year",
"json",
"geometry",
"point",
"linestring",
"polygon",
"multipoint",
"multilinestring",
"multipolygon",
"geometrycollection",
],
postgres: [
"smallint",
"int2",
"integer",
"int",
"int4",
"bigint",
"int8",
"smallserial",
"serial",
"bigserial",
"decimal",
"numeric",
"real",
"float",
"float4",
"double precision",
"float8",
"money",
"boolean",
"bool",
"char",
"character",
"varchar",
"character varying",
"text",
"bytea",
"date",
"time",
"time without time zone",
"time with time zone",
"timetz",
"timestamp",
"timestamp without time zone",
"timestamp with time zone",
"timestamptz",
"interval",
"uuid",
"json",
"jsonb",
"xml",
"bit",
"bit varying",
"varbit",
"tsvector",
"tsquery",
"cidr",
"inet",
"macaddr",
"macaddr8",
"point",
"line",
"lseg",
"box",
"path",
"polygon",
"circle",
"int4range",
"int8range",
"numrange",
"tsrange",
"tstzrange",
"daterange",
"oid",
],
sqlite: ["integer", "real", "text", "blob", "numeric"],
rqlite: ["integer", "real", "text", "blob", "numeric"],
turso: ["integer", "real", "text", "blob", "numeric"],
sqlserver: [
"bit",
"tinyint",
"smallint",
"int",
"integer",
"bigint",
"decimal",
"numeric",
"float",
"real",
"money",
"smallmoney",
"char",
"nchar",
"varchar",
"nvarchar",
"text",
"ntext",
"date",
"time",
"datetime",
"datetime2",
"smalldatetime",
"datetimeoffset",
"timestamp",
"binary",
"varbinary",
"image",
"uniqueidentifier",
"xml",
"sql_variant",
"hierarchyid",
"geography",
"geometry",
],
oracle: [
"number",
"integer",
"float",
"binary_float",
"binary_double",
"char",
"nchar",
"varchar2",
"nvarchar2",
"clob",
"nclob",
"long",
"date",
"timestamp",
"timestamp with time zone",
"timestamp with local time zone",
"interval year to month",
"interval day to second",
"raw",
"long raw",
"blob",
"bfile",
"boolean",
"json",
"vector",
"rowid",
"urowid",
"xmltype",
"sdo_geometry",
],
clickhouse: [
"Int8",
"Int16",
"Int32",
"Int64",
"Int128",
"Int256",
"UInt8",
"UInt16",
"UInt32",
"UInt64",
"UInt128",
"UInt256",
"Float16",
"Float32",
"Float64",
"Decimal",
"Decimal32",
"Decimal64",
"Decimal128",
"Decimal256",
"Bool",
"String",
"FixedString",
"Date",
"Date32",
"DateTime",
"DateTime64",
"UUID",
"IPv4",
"IPv6",
"Enum8",
"Enum16",
"Array",
"Map",
"Tuple",
"Nested",
"Nullable",
"LowCardinality",
"SimpleAggregateFunction",
"AggregateFunction",
"Point",
"Ring",
"Polygon",
"MultiPolygon",
"JSON",
],
manticoresearch: ["text", "string", "int", "bit", "bigint", "bool", "timestamp", "float", "json", "float_vector", "multi", "mva"],
informix: [
"smallint",
"integer",
"int",
"bigint",
"int8",
"serial",
"serial8",
"bigserial",
"decimal",
"numeric",
"money",
"smallfloat",
"float",
"real",
"char",
"varchar",
"lvarchar",
"nchar",
"nvarchar",
"text",
"clob",
"byte",
"blob",
"boolean",
"date",
"datetime year to second",
"datetime year to fraction",
"interval day to second",
],
questdb: ["boolean", "ipv4", "byte", "short", "char", "int", "float", "symbol", "varchar", "string", "long", "date", "timestamp", "timestamp_ns", "double", "uuid", "binary", "long256", "geohash", "array", "interval", "decimal"],
xugu: ["BOOLEAN", "INTEGER", "SMALLINT", "BIGINT", "FLOAT", "NUMERIC", "CHAR", "VARCHAR", "CLOB", "DATE", "TIME", "TIMESTAMP", "BINARY", "VARBINARY", "BLOB", "XML", "BOOL", "INT", "SHORT", "LONGINT", "LONG", "REAL", "DECIMAL", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR2"],
};
const DATA_TYPE_OPTION_ALIASES: Partial<Record<DatabaseType, string>> = {
doris: "mysql",
starrocks: "mysql",
goldendb: "mysql",
sundb: "mysql",
oscar: "oracle",
gbase: "mysql",
gaussdb: "postgres",
kwdb: "postgres",
opengauss: "postgres",
questdb: "questdb",
redshift: "postgres",
highgo: "postgres",
vastbase: "postgres",
kingbase: "postgres",
dameng: "oracle",
"oceanbase-oracle": "oracle",
iris: "oracle",
};
export function getDataTypeOptions(dbType: DatabaseType | undefined): string[] {
const key = dbType ? (DATA_TYPE_OPTION_ALIASES[dbType] ?? dbType) : "";
return DATA_TYPE_OPTIONS[key] ?? [];
}
export interface ColumnEditorControls {
length: boolean;
nullable: boolean;
primaryKey: boolean;
defaultValue: boolean;
comment: boolean;
}
const DEFAULT_COLUMN_EDITOR_CONTROLS: ColumnEditorControls = {
length: true,
nullable: true,
primaryKey: true,
defaultValue: true,
comment: true,
};
export function getColumnEditorControls(dbType: DatabaseType | undefined): ColumnEditorControls {
if (dbType === "manticoresearch") {
return {
length: true,
nullable: false,
primaryKey: false,
defaultValue: false,
comment: false,
};
}
return DEFAULT_COLUMN_EDITOR_CONTROLS;
}
export function isProtectedManticoreIdColumn(dbType: DatabaseType | undefined, columnName: string): boolean {
return dbType === "manticoresearch" && columnName.trim().toLowerCase() === "id";
}
export function canEditManticoreColumnProperties(dbType: DatabaseType | undefined, hasOriginalColumn: boolean): boolean {
return dbType === "manticoresearch" && !hasOriginalColumn;
}
export const DEFAULT_TYPE_LENGTHS: Record<string, string> = {
tinyint: "4",
"tinyint unsigned": "4",
smallint: "6",
"smallint unsigned": "6",
mediumint: "9",
"mediumint unsigned": "9",
int: "11",
"int unsigned": "11",
integer: "11",
"integer unsigned": "11",
int4: "11",
bigint: "20",
"bigint unsigned": "20",
int8: "20",
float: "10,2",
real: "10,2",
"double precision": "10,2",
double: "10,2",
decimal: "10,0",
numeric: "10,0",
number: "10,0",
char: "1",
character: "1",
varchar: "255",
"character varying": "255",
varchar2: "255",
nvarchar2: "255",
nvarchar: "255",
nchar: "1",
varbinary: "255",
binary: "1",
bit: "1",
year: "4",
};
export const QUESTDB_TYPE_LENGTHS: Record<string, string> = {
geohash: "8c",
decimal: "10,2",
};
export const DEFAULT_TYPE_LENGTH_DISABLES: string[] = [];
export const POSTGRES_TYPE_LENGTH_DISABLES: string[] = [
"bigint",
"int8",
"bigserial",
"serial8",
"boolean",
"bool",
"box",
"bytea",
"cidr",
"circle",
"date",
"double precision",
"float",
"float8",
"inet",
"integer",
"int",
"int4",
"json",
"jsonb",
"line",
"lseg",
"macaddr",
"macaddr8",
"money",
"path",
"pg_lsn",
"pg_snapshot",
"point",
"polygon",
"real",
"float4",
"smallint",
"int2",
"smallserial",
"serial2",
"serial",
"serial4",
"text",
"tsquery",
"tsvector",
"txid_snapshot",
"uuid",
"xml",
];
export function parseExtraToColumnExtra(extra: string | null | undefined, databaseType?: DatabaseType): ColumnExtra {
const result: ColumnExtra = {};
if (!extra) return result;
const lower = extra.toLowerCase().trim();
if (!lower) return result;
if (databaseType === "mysql") {
if (lower.includes("auto_increment")) {
result.autoIncrement = true;
}
if (lower.includes("on update current_timestamp")) {
result.onUpdateCurrentTimestamp = true;
}
} else if (databaseType === "postgres" || databaseType === "gaussdb" || databaseType === "kwdb" || databaseType === "opengauss" || databaseType === "questdb" || databaseType === "highgo" || databaseType === "vastbase" || databaseType === "kingbase") {
const identityMatch = lower.match(/generated\s+(by\s+default|always)\s+as\s+identity/i);
if (identityMatch) {
const sequenceMatch = lower.match(/start\s+with\s*(-?\d+)\s+increment\s+by\s*(-?\d+)/i);
result.identity = {
generation: identityMatch[1].toUpperCase() === "BY DEFAULT" ? "BY DEFAULT" : "ALWAYS",
};
if (sequenceMatch) {
result.identity.seed = Number(sequenceMatch[1]);
result.identity.increment = Number(sequenceMatch[2]);
}
}
} else if (databaseType === "sqlserver") {
if (lower.includes("identity")) {
result.autoIncrement = true;
const identityMatch = lower.match(/identity\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)/i);
if (identityMatch) {
result.identity = {
seed: Number(identityMatch[1]),
increment: Number(identityMatch[2]),
};
}
}
} else if (databaseType === "manticoresearch") {
const tokens = new Set(lower.split(/\s+/).filter(Boolean));
if (tokens.has("indexed")) result.manticoreIndexed = true;
if (tokens.has("stored")) result.manticoreStored = true;
if (tokens.has("attribute")) result.manticoreAttribute = true;
if (/secondary_index\s*=\s*['"]?1['"]?/.test(lower)) result.manticoreSecondaryIndex = true;
}
return result;
}
const MANTICORE_COLUMN_PROPERTY_TOKENS = new Set(["indexed", "stored", "attribute"]);
function splitManticoreDdlColumnLine(line: string): { name: string; dataType: string; extra: string } | null {
const trimmed = line.trim().replace(/,$/, "").trim();
if (!trimmed || trimmed.startsWith(")") || trimmed.startsWith("(")) return null;
let name = "";
let rest = "";
const quoted = trimmed.match(/^`((?:``|[^`])+)`\s+(.+)$/);
if (quoted) {
name = quoted[1]!.replace(/``/g, "`");
rest = quoted[2]!.trim();
} else {
const plain = trimmed.match(/^([A-Za-z_][\w$]*)\s+(.+)$/);
if (!plain) return null;
name = plain[1]!;
rest = plain[2]!.trim();
}
const parts = rest.split(/\s+/).filter(Boolean);
const dataType = parts.shift() ?? "";
const properties = parts.filter((part) => {
const normalized = part.toLowerCase();
return MANTICORE_COLUMN_PROPERTY_TOKENS.has(normalized) || /^secondary_index\s*=/.test(normalized);
});
if (!name || !dataType || properties.length === 0) return null;
return { name, dataType, extra: properties.join(" ") };
}
export function applyManticoreDdlColumnExtras(columns: ColumnInfo[], ddl: string): ColumnInfo[] {
if (!ddl.trim()) return columns;
const extrasByColumn = new Map<string, { dataType: string; extra: string }>();
for (const line of ddl.split(/\r?\n/)) {
const parsed = splitManticoreDdlColumnLine(line);
if (parsed) extrasByColumn.set(parsed.name.toLowerCase(), { dataType: parsed.dataType, extra: parsed.extra });
}
if (extrasByColumn.size === 0) return columns;
return columns.map((column) => {
const ddlColumn = extrasByColumn.get(column.name.toLowerCase());
if (!ddlColumn) return column;
const existingExtra = column.extra?.trim();
return {
...column,
data_type: ddlColumn.dataType || column.data_type,
extra: existingExtra ? `${existingExtra} ${ddlColumn.extra}` : ddlColumn.extra,
};
});
}
function isPostgresTextualType(dataType: string): boolean {
const baseType = dataType.split("(")[0]?.trim().replace(/\s+/g, " ").toLowerCase() ?? "";
return ["char", "character", "varchar", "character varying", "text", "bpchar", "name", "json", "jsonb", "xml", "bytea", "uuid"].includes(baseType);
}
function stripPostgresStringDefaultCast(defaultValue: string, dataType: string): string {
if (!isPostgresTextualType(dataType)) return defaultValue;
const trimmed = defaultValue.trim();
const match = trimmed.match(/^('(?:''|[^'])*')::\s*((?:character\s+varying)|character|varchar|char|text|bpchar|name|jsonb?|xml|bytea|uuid)(?:\s*\(\s*\d+\s*\))?$/i);
return match?.[1] ?? defaultValue;
}
function isWrappedByOuterParens(value: string): boolean {
if (value.length < 2 || value[0] !== "(" || value[value.length - 1] !== ")") return false;
let depth = 0;
let inString = false;
let inBracketIdentifier = false;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (inString) {
if (char === "'" && value[index + 1] === "'") {
index += 1;
} else if (char === "'") {
inString = false;
}
continue;
}
if (inBracketIdentifier) {
if (char === "]") inBracketIdentifier = false;
continue;
}
if (char === "'") {
inString = true;
continue;
}
if (char === "[") {
inBracketIdentifier = true;
continue;
}
if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
if (depth < 0) return false;
if (depth === 0 && index < value.length - 1) return false;
}
}
return depth === 0;
}
function stripSqlServerDefaultOuterParens(defaultValue: string): string {
let value = defaultValue.trim();
while (isWrappedByOuterParens(value)) {
value = value.slice(1, -1).trim();
}
return value;
}
function columnDefaultForEditor(column: ColumnInfo, databaseType?: DatabaseType): string {
const defaultValue = column.column_default ?? "";
if (databaseType === "postgres") return stripPostgresStringDefaultCast(defaultValue, column.data_type);
if (databaseType === "sqlserver") return stripSqlServerDefaultOuterParens(defaultValue);
return defaultValue;
}
export function createColumnDrafts(columns: ColumnInfo[], databaseType?: DatabaseType): EditableStructureColumn[] {
return columns.map((column, index) => {
const defaultValue = columnDefaultForEditor(column, databaseType);
return {
id: `existing:${column.name}`,
name: column.name,
dataType: column.data_type,
isNullable: column.is_nullable,
defaultValue,
comment: column.comment ?? "",
isPrimaryKey: column.is_primary_key,
extra: parseExtraToColumnExtra(column.extra, databaseType),
original: { ...column, column_default: column.column_default === null ? null : defaultValue },
originalPosition: index,
markedForDrop: false,
};
});
}
function existingColumnIdName(id: string): string | undefined {
const prefix = "existing:";
return id.startsWith(prefix) ? id.slice(prefix.length) : undefined;
}
function isNewColumnDraftId(id: string): boolean {
return id.startsWith("new:");
}
function uniqueNames(names: Array<string | undefined>): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const name of names) {
if (!name) continue;
if (seen.has(name)) continue;
seen.add(name);
result.push(name);
}
return result;
}
function findColumnDraftByName(columns: EditableStructureColumn[], names: string[], usedIndexes: Set<number>): number | undefined {
for (const name of names) {
const exactIndex = columns.findIndex((column, index) => !usedIndexes.has(index) && column.name === name);
if (exactIndex >= 0) return exactIndex;
}
for (const name of names) {
const lowerName = name.toLowerCase();
const matches = columns.map((column, index) => ({ column, index })).filter(({ column, index }) => !usedIndexes.has(index) && column.name.toLowerCase() === lowerName);
if (matches.length === 1) return matches[0]!.index;
}
return undefined;
}
export function rehydrateColumnDraftsFromMetadata(draftColumns: EditableStructureColumn[], columns: ColumnInfo[], databaseType?: DatabaseType): EditableStructureColumn[] {
const metadataDrafts = createColumnDrafts(columns, databaseType);
if (!metadataDrafts.length) return draftColumns;
if (!draftColumns.length) return metadataDrafts;
const usedMetadataIndexes = new Set<number>();
const nextColumns = draftColumns.map((column) => {
if (!column.original && isNewColumnDraftId(column.id)) return column;
const needsHydration = !column.original || column.originalPosition === undefined;
const candidates = uniqueNames([column.original?.name, existingColumnIdName(column.id), column.name]);
const metadataIndex = findColumnDraftByName(metadataDrafts, candidates, usedMetadataIndexes);
if (metadataIndex === undefined) return column;
usedMetadataIndexes.add(metadataIndex);
if (!needsHydration) return column;
const metadataDraft = metadataDrafts[metadataIndex]!;
return {
...column,
original: column.original ?? metadataDraft.original,
originalPosition: column.originalPosition ?? metadataDraft.originalPosition,
};
});
if (usedMetadataIndexes.size === 0) {
return [...metadataDrafts, ...draftColumns];
}
const missingMetadataDrafts = metadataDrafts.filter((_, index) => !usedMetadataIndexes.has(index));
return [...nextColumns, ...missingMetadataDrafts];
}
export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[] {
return indexes.map((index) => ({
id: `existing:${index.name}`,
name: index.name,
columns: [...index.columns],
nameEdited: true,
isUnique: index.is_unique,
isPrimary: index.is_primary,
filter: index.filter ?? "",
indexType: index.index_type ?? "",
includedColumns: index.included_columns ? [...index.included_columns] : [],
comment: index.comment ?? "",
original: index,
markedForDrop: false,
}));
}
export function createForeignKeyDrafts(foreignKeys: ForeignKeyInfo[]): EditableStructureForeignKey[] {
const groups = new Map<string, ForeignKeyInfo[]>();
for (const foreignKey of foreignKeys) {
const key = [foreignKey.name, foreignKey.ref_schema ?? "", foreignKey.ref_table, foreignKey.on_update ?? "", foreignKey.on_delete ?? ""].join("\u0000");
groups.set(key, [...(groups.get(key) ?? []), foreignKey]);
}
return [...groups.values()].map((group, index) => {
const first = group[0]!;
const original = {
...first,
column: group.map((foreignKey) => foreignKey.column).join(", "),
ref_column: group.map((foreignKey) => foreignKey.ref_column).join(", "),
};
return {
id: `existing:${first.name}:${index}`,
name: first.name,
column: original.column,
refSchema: first.ref_schema ?? "",
refTable: first.ref_table,
refColumn: original.ref_column,
onUpdate: first.on_update ?? "",
onDelete: first.on_delete ?? "",
original,
markedForDrop: false,
};
});
}
export function createTriggerDrafts(triggers: TriggerInfo[]): EditableStructureTrigger[] {
return triggers.map((trigger) => ({
id: `existing:${trigger.name}`,
name: trigger.name,
timing: trigger.timing,
event: trigger.event,
statement: trigger.statement ?? "",
original: trigger,
markedForDrop: false,
}));
}
export function toColumnNames(columns: string[]): string {
return columns.join(", ");
}
const AUTO_INDEX_NAME_MAX_LENGTH = 63;
function normalizeIndexNamePart(value: string): string {
const trimmed = value.trim();
const unquoted = (trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("`") && trimmed.endsWith("`")) || (trimmed.startsWith('"') && trimmed.endsWith('"')) ? trimmed.slice(1, -1) : trimmed;
return unquoted
.trim()
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
function truncateIndexName(value: string, maxLength = AUTO_INDEX_NAME_MAX_LENGTH): string {
if (value.length <= maxLength) return value;
const suffix = "_IDX";
if (!value.endsWith(suffix)) return value.slice(0, maxLength).replace(/_+$/g, "");
return `${value.slice(0, maxLength - suffix.length).replace(/_+$/g, "")}${suffix}`;
}
export function generateIndexName(tableName: string, columns: string[], maxLength = AUTO_INDEX_NAME_MAX_LENGTH): string {
const parts = [tableName, ...columns].map(normalizeIndexNamePart).filter(Boolean);
if (parts.length === 0) return "";
return truncateIndexName(`${parts.join("_")}_IDX`, maxLength);
}
export function generateUniqueIndexName(tableName: string, columns: string[], existingNames: Iterable<string>, maxLength = AUTO_INDEX_NAME_MAX_LENGTH): string {
const base = generateIndexName(tableName, columns, maxLength);
if (!base) return "";
const taken = new Set([...existingNames].map((name) => name.trim().toLowerCase()).filter(Boolean));
if (!taken.has(base.toLowerCase())) return base;
for (let counter = 2; counter < 10_000; counter++) {
const suffix = `_${counter}`;
const stem = base.length + suffix.length <= maxLength ? base : base.slice(0, maxLength - suffix.length).replace(/_+$/g, "");
const candidate = `${stem}${suffix}`;
if (!taken.has(candidate.toLowerCase())) return candidate;
}
return base;
}
export function splitDataType(raw: string): { baseType: string; params: string } {
const trimmed = raw.trim();
const parenIdx = trimmed.indexOf("(");
if (parenIdx === -1) return { baseType: trimmed, params: "" };
const closeIdx = trimmed.lastIndexOf(")");
const baseTypePrefix = trimmed.slice(0, parenIdx).trim();
const params = trimmed.slice(parenIdx + 1, closeIdx).trim();
const suffix = trimmed
.slice(closeIdx + 1)
.trim()
.replace(/\s+/g, " ");
const baseType = /^(?:signed|unsigned|zerofill)(?:\s+(?:signed|unsigned|zerofill))*$/i.test(suffix) ? `${baseTypePrefix} ${suffix}`.trim() : baseTypePrefix;
return { baseType, params };
}
export function isSqlServerIdentityCompatibleDataType(rawDataType: string): boolean {
const { baseType, params } = splitDataType(rawDataType);
const normalized = baseType.trim().replace(/\s+/g, " ").toLowerCase();
if (["tinyint", "smallint", "int", "integer", "bigint"].includes(normalized)) return true;
if (normalized !== "decimal" && normalized !== "numeric") return false;
const normalizedParams = params.replace(/\s+/g, "");
if (!normalizedParams) return true;
const parts = normalizedParams.split(",");
if (parts.length === 1) return /^\d+$/.test(parts[0] ?? "");
if (parts.length !== 2) return false;
const [precision, scale] = parts;
return /^\d+$/.test(precision ?? "") && scale === "0";
}
export function combineDataType(baseType: string, params: string): string {
const type = baseType.trim();
const p = params.trim();
if (!type) return "";
if (!p) return type;
return `${type}(${p})`;
}
export function combineDataTypeForDatabase(dbType: DatabaseType | undefined, baseType: string, params: string): string {
if (isDataTypeLengthDisabled(dbType, baseType)) {
return baseType;
}
const normalizedParams = normalizeDataTypeParams(dbType, baseType, params);
const mysqlType = combineMysqlNumericAttributeType(dbType, baseType, normalizedParams);
if (mysqlType) return mysqlType;
return combineDataType(baseType, normalizedParams);
}
export function dataTypeLengthInputValue(dbType: DatabaseType | undefined, rawDataType: string): string {
const parsed = splitDataType(rawDataType);
return isDataTypeLengthDisabled(dbType, parsed.baseType) ? "" : parsed.params;
}
export function normalizeDataTypeParams(dbType: DatabaseType | undefined, baseType: string, params: string): string {
const p = params.trim();
if (!p) return "";
if (!isTemporalPrecisionType(dbType, baseType)) return p;
return isValidTemporalPrecision(dbType, p) ? p : "";
}
function isTemporalPrecisionType(dbType: DatabaseType | undefined, baseType: string): boolean {
const normalized = baseType.trim().replace(/\s+/g, " ").toLowerCase();
switch (dbType) {
case "mysql":
case "doris":
case "starrocks":
case "goldendb":
case "sundb":
return ["time", "datetime", "timestamp"].includes(normalized);
case "postgres":
case "gaussdb":
case "kwdb":
case "opengauss":
case "highgo":
case "vastbase":
case "kingbase":
case "redshift":
return ["time", "time without time zone", "time with time zone", "timestamp", "timestamp without time zone", "timestamp with time zone"].includes(normalized);
case "sqlserver":
return ["time", "datetime2", "datetimeoffset"].includes(normalized);
case "oracle":
case "dameng":
case "oceanbase-oracle":
return ["timestamp", "timestamp with time zone", "timestamp with local time zone"].includes(normalized);
case "questdb":
return ["timestamp"].includes(normalized);
default:
return false;
}
}
function combineMysqlNumericAttributeType(dbType: DatabaseType | undefined, baseType: string, params: string): string | null {
if (!params || !isMysqlLikeStructureType(dbType)) return null;
const parts = baseType.trim().replace(/\s+/g, " ").split(" ").filter(Boolean);
const typeName = parts[0]?.toLowerCase();
if (!typeName || !["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "real", "double", "float", "decimal", "numeric"].includes(typeName)) return null;
const attrIndex = parts.findIndex((part) => ["signed", "unsigned", "zerofill"].includes(part.toLowerCase()));
if (attrIndex === -1) return null;
if (!parts.slice(attrIndex).every((part) => ["signed", "unsigned", "zerofill"].includes(part.toLowerCase()))) return null;
return `${parts.slice(0, attrIndex).join(" ")}(${params}) ${parts.slice(attrIndex).join(" ")}`;
}
function isMysqlLikeStructureType(dbType: DatabaseType | undefined): boolean {
return dbType === "mysql" || dbType === "doris" || dbType === "starrocks" || dbType === "goldendb" || dbType === "sundb" || dbType === "databend";
}
function isValidTemporalPrecision(dbType: DatabaseType | undefined, params: string): boolean {
if (!/^\d+$/.test(params)) return false;
const value = Number(params);
const max = dbType === "oracle" || dbType === "dameng" || dbType === "oceanbase-oracle" ? 9 : 6;
return Number.isInteger(value) && value >= 0 && value <= max && String(value) === params;
}
export function getDefaultLengthForType(_dbType: DatabaseType | undefined, baseType: string): string {
const key = baseType.trim().toLowerCase();
if (_dbType === "questdb") {
return QUESTDB_TYPE_LENGTHS[key] ?? "";
} else {
return DEFAULT_TYPE_LENGTHS[key] ?? "";
}
}
export function isDataTypeLengthDisabled(_dbType: DatabaseType | undefined, baseType: string): boolean {
const key = baseType.trim().toLowerCase();
if (_dbType === "questdb") {
return key !== "geohash" && key !== "decimal";
} else if (_dbType === "manticoresearch") {
return key !== "bit" && key !== "float_vector";
} else if (_dbType === "postgres" || _dbType === "gaussdb" || _dbType === "kwdb" || _dbType === "opengauss" || _dbType === "highgo" || _dbType === "vastbase" || _dbType === "kingbase") {
return POSTGRES_TYPE_LENGTH_DISABLES.includes(key);
} else if (isMysqlLikeStructureType(_dbType)) {
return key === "enum" || key === "set";
} else {
return DEFAULT_TYPE_LENGTH_DISABLES.includes(key);
}
}
export function buildStructureTargetLabel(connectionName: string | undefined, database: string | undefined, schema: string | undefined, tableName: string | undefined): string {
const parts = [connectionName, database];
if (schema && schema !== database) parts.push(schema);
if (tableName) parts.push(tableName);
return parts.filter(Boolean).join(" / ");
}
+658
View File
@@ -0,0 +1,658 @@
import type { ObjectInfo, TableInfo, TreeNode, TreeNodeType } from "@/types/database";
import { normalizeSidebarObjectKind, type SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
const databaseObjectNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
export function normalizeDatabaseObjectName(name: string): string {
return name.trim();
}
export function buildTableTreeNodes({ nodeId, connectionId, database, schema, tables }: { nodeId: string; connectionId: string; database: string; schema?: string; tables: TableInfo[] }): TreeNode[] {
const entries = tables.flatMap((table) => {
const name = normalizeDatabaseObjectName(table.name);
if (!name) return [];
const objectType = normalizeObjectType(table.table_type);
const childSchema = schema;
return [
makeTableTreeEntry({
nodeId,
connectionId,
database,
schema: childSchema,
includeSchemaInId: false,
name,
objectType,
tableType: table.table_type,
comment: table.comment,
parentSchema: table.parent_schema,
parentName: table.parent_name,
}),
];
});
return buildPartitionTree(entries, connectionId, database);
}
type TableTreeEntry = {
key: string;
objectType: DatabaseObjectTreeKind;
schema?: string;
parentSchema?: string;
parentName?: string;
node: TreeNode;
};
function makeTableTreeEntry({
nodeId,
connectionId,
database,
schema,
includeSchemaInId,
name,
objectType,
tableType,
comment,
parentSchema,
parentName,
}: {
nodeId: string;
connectionId: string;
database: string;
schema?: string;
includeSchemaInId?: boolean;
name: string;
objectType: DatabaseObjectTreeKind;
tableType?: string;
comment?: string | null;
parentSchema?: string | null;
parentName?: string | null;
}): TableTreeEntry {
const normalizedParentSchema = parentSchema ? normalizeDatabaseObjectName(parentSchema) : undefined;
const normalizedParentName = parentName ? normalizeDatabaseObjectName(parentName) : undefined;
const nodeIdSchemaSuffix = includeSchemaInId !== false && schema ? `${schema}:` : "";
const node: TreeNode = {
id: `${nodeId}:${nodeIdSchemaSuffix}${name}`,
label: name,
type: objectType === "VIEW" ? ("view" as const) : objectType === "MATERIALIZED_VIEW" ? ("materialized_view" as const) : ("table" as const),
tableType,
comment,
connectionId,
database,
schema,
isExpanded: false,
children: [],
};
if (normalizedParentSchema) node.partitionParentSchema = normalizedParentSchema;
if (normalizedParentName) node.partitionParentName = normalizedParentName;
return {
key: objectIdentityKey(objectType, schema, name),
objectType,
schema,
parentSchema: normalizedParentSchema,
parentName: normalizedParentName,
node,
};
}
function objectIdentityKey(objectType: string, schema: string | undefined, name: string) {
return `${objectType}\0${(schema || "").toLowerCase()}\0${name.toLowerCase()}`;
}
type PrefixSortInfo = {
rootName: string;
leadingSegments: number;
};
function nameSegments(name: string): string[] {
return name.toLowerCase().split("_").filter(Boolean);
}
function findSubsequence(haystack: readonly string[], needle: readonly string[]): number {
if (!needle.length || needle.length > haystack.length) return -1;
for (let start = 0; start <= haystack.length - needle.length; start += 1) {
let matched = true;
for (let offset = 0; offset < needle.length; offset += 1) {
if (haystack[start + offset] !== needle[offset]) {
matched = false;
break;
}
}
if (matched) return start;
}
return -1;
}
function buildPrefixSortIndex(names: readonly string[]): Map<string, PrefixSortInfo> {
const segmentsByName = new Map<string, string[]>();
const nameBySegmentKey = new Map<string, string>();
for (const name of names) {
const normalized = name.toLowerCase();
if (segmentsByName.has(normalized)) continue;
const segments = nameSegments(name);
segmentsByName.set(normalized, segments);
const segmentKey = segments.join("_");
if (!nameBySegmentKey.has(segmentKey)) nameBySegmentKey.set(segmentKey, normalized);
}
const resolved = new Map<string, PrefixSortInfo>();
const resolve = (name: string): PrefixSortInfo => {
const cached = resolved.get(name);
if (cached) return cached;
const segments = segmentsByName.get(name) ?? [];
let bestAnchor: { name: string; segmentCount: number; start: number } | undefined;
for (let segmentCount = segments.length - 1; segmentCount > 0 && !bestAnchor; segmentCount -= 1) {
for (let start = 0; start <= segments.length - segmentCount; start += 1) {
const candidateName = nameBySegmentKey.get(segments.slice(start, start + segmentCount).join("_"));
if (!candidateName || candidateName === name) continue;
bestAnchor = { name: candidateName, segmentCount, start };
break;
}
}
if (!bestAnchor) {
const info = { rootName: name, leadingSegments: 0 };
resolved.set(name, info);
return info;
}
const anchor = resolve(bestAnchor.name);
const rootSegments = segmentsByName.get(anchor.rootName) ?? [];
const rootStart = findSubsequence(segments, rootSegments);
const info = {
rootName: anchor.rootName,
leadingSegments: rootStart >= 0 ? rootStart : bestAnchor.start + anchor.leadingSegments,
};
resolved.set(name, info);
return info;
};
for (const name of segmentsByName.keys()) resolve(name);
return resolved;
}
function comparePrefixPriorityNames(left: string, right: string, index: ReadonlyMap<string, PrefixSortInfo>): number {
const leftName = left.toLowerCase();
const rightName = right.toLowerCase();
const leftInfo = index.get(leftName) ?? { rootName: leftName, leadingSegments: 0 };
const rightInfo = index.get(rightName) ?? { rootName: rightName, leadingSegments: 0 };
const rootCompared = databaseObjectNameCollator.compare(leftInfo.rootName, rightInfo.rootName);
if (rootCompared !== 0) return rootCompared;
if (leftInfo.leadingSegments !== rightInfo.leadingSegments) {
return leftInfo.leadingSegments - rightInfo.leadingSegments;
}
return databaseObjectNameCollator.compare(left, right);
}
export function createDatabaseObjectNameComparator(names: readonly string[]): (left: string, right: string) => number {
const index = buildPrefixSortIndex(names);
return (left, right) => comparePrefixPriorityNames(left, right, index);
}
export function sortDatabaseObjectsByPrefixPriority<T>(items: readonly T[], getName: (item: T) => string): T[] {
const compareNames = createDatabaseObjectNameComparator(items.map(getName));
return [...items].sort((left, right) => compareNames(getName(left), getName(right)));
}
export function sortDatabaseObjectsByName<T>(items: readonly T[], getName: (item: T) => string): T[] {
return [...items].sort((left, right) => databaseObjectNameCollator.compare(getName(left), getName(right)));
}
export function mergeTableInfosIntoObjects(objects: readonly ObjectInfo[], tables: readonly TableInfo[], schema?: string): ObjectInfo[] {
const merged = [...objects];
const seen = new Set(
merged.map((obj) => {
const name = normalizeDatabaseObjectName(obj.name);
const objectSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema || "";
return `${normalizeObjectType(obj.object_type)}\0${objectSchema.toLowerCase()}\0${name.toLowerCase()}`;
}),
);
for (const table of tables) {
const objectType = normalizeObjectType(table.table_type);
if (objectType !== "TABLE" && objectType !== "VIEW" && objectType !== "MATERIALIZED_VIEW") continue;
const name = normalizeDatabaseObjectName(table.name);
if (!name) continue;
const matchingObject = objects.find((obj) => {
const objName = normalizeDatabaseObjectName(obj.name);
if (objName.toLowerCase() !== name.toLowerCase()) return false;
return normalizeObjectType(obj.object_type) === objectType;
});
const tableSchema = schema ?? (matchingObject?.schema ? normalizeDatabaseObjectName(matchingObject.schema) : undefined);
const key = `${objectType}\0${(tableSchema || "").toLowerCase()}\0${name.toLowerCase()}`;
if (seen.has(key)) {
// Table already in objects — merge comment if missing
if (matchingObject && table.comment && !matchingObject.comment) {
matchingObject.comment = table.comment;
}
continue;
}
seen.add(key);
merged.push({
name,
// Keep original driver-reported type (e.g. TDengine STABLE) so
// downstream template strategies can distinguish special table kinds.
object_type: table.table_type,
schema: tableSchema,
comment: table.comment,
created_at: undefined,
updated_at: undefined,
parent_schema: table.parent_schema,
parent_name: table.parent_name,
});
}
return merged;
}
export function filterSimpleSidebarSupplementalObjects(objects: readonly ObjectInfo[]): ObjectInfo[] {
return objects.filter((object) => {
const objectType = normalizeObjectType(object.object_type);
return objectType !== "TABLE" && objectType !== "VIEW" && objectType !== "MATERIALIZED_VIEW";
});
}
function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, database: string): TreeNode[] {
const orderedEntries = sortDatabaseObjectsByName(entries, (entry) => entry.node.label);
const byKey = new Map<string, TableTreeEntry>();
for (const entry of orderedEntries) {
byKey.set(entry.key, entry);
}
const childrenByParent = new Map<string, TableTreeEntry[]>();
const childKeys = new Set<string>();
for (const entry of orderedEntries) {
if (entry.objectType !== "TABLE" || !entry.parentName) continue;
const parentSchema = entry.parentSchema || entry.schema;
const parentKey = objectIdentityKey("TABLE", parentSchema, entry.parentName);
const parent = byKey.get(parentKey);
if (!parent || parent.key === entry.key) continue;
const children = childrenByParent.get(parent.key) ?? [];
children.push(entry);
childrenByParent.set(parent.key, children);
childKeys.add(entry.key);
}
const materialize = (entry: TableTreeEntry): TreeNode => {
const partitionChildren = childrenByParent.get(entry.key);
if (!partitionChildren?.length) return entry.node;
const partitionGroup: TreeNode = {
id: `${entry.node.id}:__partitions`,
label: "tree.partitions",
type: "group-partitions",
connectionId,
database,
schema: entry.schema,
tableName: entry.node.label,
objectCount: partitionChildren.length,
isExpanded: false,
children: partitionChildren.map(materialize),
};
entry.node.children = [partitionGroup];
entry.node.hiddenChildren = [partitionGroup];
return entry.node;
};
return orderedEntries.filter((entry) => !childKeys.has(entry.key)).map(materialize);
}
function partitionGroupChildren(node: TreeNode): TreeNode[] {
const children = node.children?.filter((child) => child.type === "group-partitions") ?? [];
if (children.length) return children;
return node.hiddenChildren?.filter((child) => child.type === "group-partitions") ?? [];
}
export function tablePartitionGroups(node: TreeNode): TreeNode[] {
return partitionGroupChildren(node);
}
export function hasTablePartitionGroups(node: TreeNode): boolean {
return partitionGroupChildren(node).length > 0;
}
export function mergeTableTreePageChildren(currentChildren: TreeNode[], pageChildren: TreeNode[], connectionId: string, database: string): TreeNode[] {
const roots = [...currentChildren];
const nodesByKey = new Map<string, TreeNode>();
const rootKeys = new Set<string>();
const nodeKey = (node: TreeNode) => objectIdentityKey("TABLE", node.schema, node.label);
const collect = (nodes: readonly TreeNode[]) => {
for (const node of nodes) {
if (node.type === "table") {
nodesByKey.set(nodeKey(node), node);
}
collect(node.children ?? []);
collect(node.hiddenChildren?.filter((child) => !(node.children ?? []).includes(child)) ?? []);
}
};
collect(roots);
for (const node of roots) {
if (node.type === "table") rootKeys.add(nodeKey(node));
}
const ensurePartitionGroup = (parent: TreeNode): TreeNode => {
const existing = partitionGroupChildren(parent)[0];
if (existing) return existing;
const group: TreeNode = {
id: `${parent.id}:__partitions`,
label: "tree.partitions",
type: "group-partitions",
connectionId,
database,
schema: parent.schema,
tableName: parent.label,
objectCount: 0,
isExpanded: false,
children: [],
};
parent.children = [...(parent.children ?? []), group];
parent.hiddenChildren = [...(parent.hiddenChildren ?? []), group];
return group;
};
const addToParent = (parent: TreeNode, child: TreeNode) => {
const group = ensurePartitionGroup(parent);
const children = group.children ?? [];
if (!children.some((node) => nodeKey(node) === nodeKey(child))) {
group.children = sortDatabaseObjectsByName([...children, child], (node) => node.label);
group.objectCount = group.children.length;
}
};
const addNode = (node: TreeNode) => {
if (node.type !== "table") {
roots.push(node);
return;
}
const key = nodeKey(node);
if (nodesByKey.has(key)) return;
const parentName = node.partitionParentName;
const parentSchema = node.partitionParentSchema || node.schema;
const parentKey = parentName ? objectIdentityKey("TABLE", parentSchema, parentName) : "";
const parent = parentKey ? nodesByKey.get(parentKey) : undefined;
nodesByKey.set(key, node);
if (parent && parent !== node) {
addToParent(parent, node);
return;
}
if (!rootKeys.has(key)) {
roots.push(node);
rootKeys.add(key);
}
};
const flattenIncomingTables = (node: TreeNode): TreeNode[] => {
if (node.type !== "table") return [node];
const descendants = partitionGroupChildren(node)
.flatMap((group) => group.children ?? [])
.flatMap(flattenIncomingTables);
node.children = (node.children ?? []).filter((child) => child.type !== "group-partitions");
node.hiddenChildren = (node.hiddenChildren ?? []).filter((child) => child.type !== "group-partitions");
return [node, ...descendants];
};
for (const node of pageChildren.flatMap(flattenIncomingTables)) {
addNode(node);
}
return sortDatabaseObjectsByName(roots, (node) => node.label);
}
export type DatabaseObjectTreeKind = SidebarObjectKind;
function buildObjectTreeEntries({ nodeId, connectionId, database, schema, objects, objectType }: { nodeId: string; connectionId: string; database: string; schema?: string; objects: ObjectInfo[]; objectType: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" }): TreeNode[] {
const entries = objects.flatMap((obj) => {
const name = normalizeDatabaseObjectName(obj.name);
if (!name) return [];
const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema;
return [
makeTableTreeEntry({
nodeId,
connectionId,
database,
schema: childSchema,
name,
objectType,
tableType: obj.object_type,
comment: obj.comment,
parentSchema: obj.parent_schema,
parentName: obj.parent_name,
}),
];
});
return buildPartitionTree(entries, connectionId, database);
}
export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, schema, objects }: { nodeId: string; connectionId: string; database: string; schema?: string; objects: ObjectInfo[] }): TreeNode[] {
const seen = new Set<string>();
const tableEntries: TableTreeEntry[] = [];
const objectNodes: TreeNode[] = [];
for (const obj of objects) {
const objectType = normalizeObjectType(obj.object_type);
if (!["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"].includes(objectType)) {
continue;
}
const name = normalizeDatabaseObjectName(obj.name);
if (!name) continue;
const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema;
const dedupeKey = `${objectType}\0${(childSchema || "").toLowerCase()}\0${name.toLowerCase()}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
const entry = makeTableTreeEntry({
nodeId,
connectionId,
database,
schema: childSchema,
name,
objectType,
tableType: obj.object_type,
comment: obj.comment,
parentSchema: obj.parent_schema,
parentName: obj.parent_name,
});
if (objectType === "TABLE") {
tableEntries.push(entry);
} else {
const simpleNodeType = simpleObjectNodeType(objectType);
objectNodes.push({
id: objectType === "VIEW" || objectType === "MATERIALIZED_VIEW" ? entry.node.id : `${nodeId}:${childSchema ? `${childSchema}:` : ""}${name}:${objectType}`,
label: name,
type: simpleNodeType,
comment: obj.comment,
connectionId,
database,
schema: childSchema,
isExpanded: false,
children: undefined,
});
}
}
return [...buildPartitionTree(tableEntries, connectionId, database), ...sortDatabaseObjectsByName(objectNodes, (node) => node.label)];
}
function simpleObjectNodeType(objectType: DatabaseObjectTreeKind): TreeNodeType {
if (objectType === "VIEW") return "view";
if (objectType === "MATERIALIZED_VIEW") return "materialized_view";
if (objectType === "PROCEDURE") return "procedure";
if (objectType === "FUNCTION") return "function";
if (objectType === "SEQUENCE") return "sequence";
if (objectType === "PACKAGE_BODY") return "package-body";
if (objectType === "PACKAGE") return "package";
return "table";
}
function normalizeObjectType(type: string): DatabaseObjectTreeKind {
return normalizeSidebarObjectKind(type);
}
const groupDefs: Array<{
key: string;
label: string;
objectTypes: DatabaseObjectTreeKind[];
nodeType: TreeNodeType;
childType: TreeNodeType | ((objectType: DatabaseObjectTreeKind) => TreeNodeType);
}> = [
{ key: "__tables", label: "tree.tables", objectTypes: ["TABLE"], nodeType: "group-tables", childType: "table" },
{ key: "__views", label: "tree.views", objectTypes: ["VIEW"], nodeType: "group-views", childType: "view" },
{
key: "__materialized_views",
label: "tree.materializedViews",
objectTypes: ["MATERIALIZED_VIEW"],
nodeType: "group-materialized-views",
childType: "materialized_view",
},
{
key: "__procedures",
label: "tree.procedures",
objectTypes: ["PROCEDURE"],
nodeType: "group-procedures",
childType: "procedure",
},
{
key: "__functions",
label: "tree.functions",
objectTypes: ["FUNCTION"],
nodeType: "group-functions",
childType: "function",
},
{
key: "__sequences",
label: "tree.sequences",
objectTypes: ["SEQUENCE"],
nodeType: "group-sequences",
childType: "sequence",
},
{
key: "__packages",
label: "tree.packages",
objectTypes: ["PACKAGE", "PACKAGE_BODY"],
nodeType: "group-packages",
childType: (objectType) => (objectType === "PACKAGE_BODY" ? "package-body" : "package"),
},
];
const objectGroupNodeTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages"]);
export function buildObjectGroupPlaceholderNodes({ nodeId, connectionId, database, schema, objectTypes }: { nodeId: string; connectionId: string; database: string; schema?: string; objectTypes: DatabaseObjectTreeKind[] }): TreeNode[] {
const supported = new Set(objectTypes);
return groupDefs
.filter((def) => def.objectTypes.some((objectType) => supported.has(objectType)))
.map((def) => ({
id: `${nodeId}:${def.key}`,
label: def.label,
type: def.nodeType,
connectionId,
database,
schema,
isExpanded: false,
children: [],
}));
}
export function objectGroupRefreshParentId(node: TreeNode): string | null {
if (!objectGroupNodeTypes.has(node.type)) return null;
const suffixStart = node.id.lastIndexOf(":__");
if (suffixStart < 0) return null;
return node.id.slice(0, suffixStart);
}
export function objectTypesForGroupNode(type: TreeNodeType): DatabaseObjectTreeKind[] | null {
return groupDefs.find((def) => def.nodeType === type)?.objectTypes ?? null;
}
export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, schema, objects }: { nodeId: string; connectionId: string; database: string; schema?: string; objects: ObjectInfo[] }): TreeNode[] {
const buckets = new Map<string, ObjectInfo[]>();
const seen = new Set<string>();
for (const obj of objects) {
const name = normalizeDatabaseObjectName(obj.name);
if (!name) continue;
const t = normalizeObjectType(obj.object_type);
const objectSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema || "";
const key = `${t}\0${objectSchema.toLowerCase()}\0${name.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
const arr = buckets.get(t) ?? [];
arr.push({ ...obj, name, schema: obj.schema ? normalizeDatabaseObjectName(obj.schema) : obj.schema });
buckets.set(t, arr);
}
const groups: TreeNode[] = [];
for (const def of groupDefs) {
const items = def.objectTypes.flatMap((objectType) => buckets.get(objectType) ?? []);
if (!items?.length) continue;
const isExpandable = def.nodeType === "group-tables" || def.nodeType === "group-views" || def.nodeType === "group-materialized-views";
const children = isExpandable
? buildObjectTreeEntries({
nodeId: `${nodeId}:${def.key}`,
connectionId,
database,
schema,
objects: items,
objectType: def.objectTypes[0] as "TABLE" | "VIEW" | "MATERIALIZED_VIEW",
})
: sortDatabaseObjectsByName(items, (obj) => obj.name).map((obj) => {
const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema;
const objectType = normalizeObjectType(obj.object_type);
const childType = typeof def.childType === "function" ? def.childType(objectType) : def.childType;
const objectTypeSuffix = objectType === "PACKAGE" || objectType === "PACKAGE_BODY" ? `:${objectType}` : "";
return {
id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}${objectTypeSuffix}`,
label: obj.name,
type: childType,
comment: obj.comment,
connectionId,
database,
schema: childSchema,
isExpanded: false,
children: undefined,
};
});
groups.push({
id: `${nodeId}:${def.key}`,
label: def.label,
type: def.nodeType,
connectionId,
database,
schema,
objectCount: items.length,
isExpanded: false,
children,
});
}
return groups;
}
export function expandCachedObjectBrowserNodes(nodes: TreeNode[]): TreeNode[] {
return nodes.flatMap((node) => {
if (node.type === "object-browser") return node.hiddenChildren ?? [];
if (!node.children) return [node];
return [
{
...node,
children: expandCachedObjectBrowserNodes(node.children),
},
];
});
}
+13
View File
@@ -0,0 +1,13 @@
import type { DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
export type BuildViewDdlInput = {
databaseType?: DatabaseType;
schema?: string | null;
name: string;
source: string;
};
export function buildViewDdl(input: BuildViewDdlInput): Promise<string> {
return api.buildViewDdlSql(input);
}