refactor(desktop): organize lib modules by domain
This commit is contained in:
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 ?? "");
|
||||
}
|
||||
@@ -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(" / ");
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user