Add TDengine support and editable grid handling

This commit is contained in:
t8y2
2026-05-16 11:33:42 +08:00
parent 69f05cdf6f
commit e9621ef06a
31 changed files with 534 additions and 59 deletions
@@ -201,7 +201,7 @@ const driverProfiles: Record<
kylin: { type: "kylin", port: 7070, user: "ADMIN", label: "Apache Kylin", icon: "kylin" },
sundb: { type: "sundb", port: 22000, user: "root", label: "SunDB", icon: "sundb" },
jdbc: { type: "jdbc", port: 0, user: "", label: "JDBC", icon: "jdbc" },
tdengine: { type: "mysql", port: 6030, user: "root", label: "TDengine", icon: "tdengine" },
tdengine: { type: "tdengine", port: 6041, user: "root", label: "TDengine", icon: "tdengine" },
custom_mysql: {
type: "mysql",
port: 3306,
@@ -267,14 +267,15 @@ watch(
if (config) {
const profile = profileForConfig(config);
editingId.value = config.id;
const profileConfig = driverProfiles[profile];
form.value = {
name: config.name,
db_type: config.db_type,
db_type: profileConfig?.type || config.db_type,
driver_profile: profile,
driver_label: config.driver_label || driverProfiles[profile]?.label || config.db_type,
url_params: config.url_params || "",
host: config.host,
port: config.port,
port: profile === "tdengine" && (config.port === 0 || config.port === 6030) ? 6041 : config.port,
username: config.username,
password: config.password,
database: config.database,
+7 -1
View File
@@ -77,6 +77,7 @@ import {
canEditExistingTableRows,
hiveTablePropertiesIndicateTransactional,
isHiddenGridColumn,
isTdengineExistingRowReadonlyColumn,
usesSyntheticRowIdKey,
} from "@/lib/tableEditing";
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
@@ -1153,7 +1154,12 @@ function canEditRowItem(item: RowItem | undefined): boolean {
}
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
return canEditRowItem(item) && canEditColumn(columnIndex);
if (!canEditRowItem(item) || !canEditColumn(columnIndex)) return false;
if (!item?.isNew) {
const column = props.result.columns[columnIndex] ?? "";
if (isTdengineExistingRowReadonlyColumn(props.databaseType, column, props.tableMeta?.columns ?? [])) return false;
}
return true;
}
function canDeleteRowItem(item: RowItem | undefined): boolean {
+2 -1
View File
@@ -74,13 +74,14 @@ async function openTableTarget(target: NavigationTarget) {
columns,
primaryKeys,
});
if (useRowId) {
if (useRowId || config.db_type === "tdengine") {
const newSql = buildTableSelectSql({
databaseType: config.db_type,
schema: target.schema,
tableName: target.tableName,
whereInput: target.whereInput,
primaryKeys,
columns: columns.map((column) => column.name),
includeRowId: true,
});
queryStore.updateSql(tabId, newSql);
+3
View File
@@ -63,6 +63,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
case "dameng":
return "dm://user:password@host:port";
case "tdengine":
return "tdengine://user:password@host:6041/database";
case "jdbc":
return "jdbc:mysql://host:3306/database";
+2
View File
@@ -41,6 +41,8 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
dameng: { type: "dameng", profile: "dm", label: "DM (Dameng)", defaultPort: 5236 },
gaussdb: { type: "gaussdb", profile: "gaussdb", label: "GaussDB", defaultPort: 5432 },
opengauss: { type: "gaussdb", profile: "opengauss", label: "openGauss", defaultPort: 5432 },
tdengine: { type: "tdengine", profile: "tdengine", label: "TDengine", defaultPort: 6041 },
"taos-ws": { type: "tdengine", profile: "tdengine", label: "TDengine", defaultPort: 6041 },
};
const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
+101 -2
View File
@@ -1,5 +1,5 @@
import type { DatabaseType } from "@/types/database";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "./tableEditing.ts";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, DBX_TDENGINE_TBNAME_COLUMN } from "./tableEditing.ts";
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
export type GridCellValue = string | number | boolean | null;
@@ -8,6 +8,7 @@ export interface DataGridTableMeta {
schema?: string;
tableName: string;
primaryKeys: string[];
columns?: DataGridColumnInfo[];
}
export interface DataGridColumnInfo {
@@ -108,6 +109,7 @@ function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): st
export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options);
if (options.databaseType === "tdengine") return buildTdengineDataGridSaveStatements(options);
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const table = qualifiedTableName({
@@ -155,6 +157,81 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
return statements;
}
function buildTdengineDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const statements: string[] = [];
const canOverwriteExistingRows = saveColumns.some((column) => column?.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN);
if (canOverwriteExistingRows) {
for (const [rowIndex, changes] of options.dirtyRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const afterRow = [...row];
for (const [columnIndex, value] of changes) {
afterRow[columnIndex] = value;
}
const statement = buildTdengineInsertStatement(options, saveColumns, afterRow);
if (statement) statements.push(statement);
}
}
for (const row of options.newRows) {
const statement = buildTdengineInsertStatement(options, saveColumns, row);
if (statement) statements.push(statement);
}
return statements;
}
function buildTdengineInsertStatement(
options: DataGridSaveStatementOptions,
saveColumns: Array<string | undefined>,
row: GridCellValue[],
): string | undefined {
const tbname = tdengineTbnameValue(saveColumns, row);
const table = qualifiedTableName({
databaseType: "tdengine",
schema: options.tableMeta.schema,
tableName: options.tableMeta.tableName,
});
const tagColumns = tdengineTagColumns(options.tableMeta.columns);
const insertPairs = saveColumns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
.filter((pair) => pair.value !== null && pair.value !== undefined)
.filter((pair) => tdengineCanInsertColumn(pair.column, options.tableMeta.tableName, tbname, tagColumns));
if (!insertPairs.length) return undefined;
const columns = insertPairs.map((pair) => quoteIdent("tdengine", pair.column)).join(", ");
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, "tdengine")).join(", ");
return `INSERT INTO ${table} (${columns}) VALUES (${values});`;
}
function tdengineTbnameValue(saveColumns: Array<string | undefined>, row: GridCellValue[]): string | undefined {
const index = saveColumns.findIndex((column) => column?.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN);
if (index < 0) return undefined;
const value = row[index];
return value === null || value === undefined ? undefined : String(value);
}
function tdengineCanInsertColumn(
column: string,
tableName: string,
tbname: string | undefined,
tagColumns: Set<string>,
): boolean {
const normalized = column.toLowerCase();
const targetIsChildTable = !tbname || tbname === tableName;
if (!targetIsChildTable) return true;
if (normalized === DBX_TDENGINE_TBNAME_COLUMN) return false;
return !tagColumns.has(normalized);
}
function tdengineTagColumns(columns: DataGridColumnInfo[] | undefined): Set<string> {
return new Set(
(columns ?? []).filter((column) => /\btag\b/i.test(column.extra ?? "")).map((column) => column.name.toLowerCase()),
);
}
export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options);
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
@@ -253,10 +330,32 @@ export function formatGridSqlLiteral(value: GridCellValue, databaseType?: Databa
if (typeof value === "number" && Number.isFinite(value)) return String(value);
const text = String(value);
if (text === "") return databaseType === "sqlserver" ? "N''" : "''";
const escaped = `'${text.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
const literalText = databaseType === "tdengine" ? formatTdengineTimestampLiteralText(text) : text;
const escaped = `'${literalText.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
return databaseType === "sqlserver" ? `N${escaped}` : escaped;
}
function formatTdengineTimestampLiteralText(text: string): string {
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d{1,9})?$/.exec(text);
if (!match) return text;
return `${match[1]}T${match[2]}${normalizeFractionalSeconds(match[3])}${localTimezoneOffsetSuffix(text)}`;
}
function normalizeFractionalSeconds(fraction: string | undefined): string {
if (!fraction) return ".000";
return fraction.length >= 4 ? fraction.slice(0, 4) : fraction.padEnd(4, "0");
}
function localTimezoneOffsetSuffix(text: string): string {
const date = new Date(text.replace(" ", "T"));
const offsetMinutes = Number.isNaN(date.getTime()) ? new Date().getTimezoneOffset() : date.getTimezoneOffset();
const sign = offsetMinutes <= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const hours = String(Math.floor(abs / 60)).padStart(2, "0");
const minutes = String(abs % 60).padStart(2, "0");
return `${sign}${hours}:${minutes}`;
}
function buildPrimaryKeyWhere(
databaseType: DatabaseType | undefined,
primaryKeys: string[],
+4
View File
@@ -15,6 +15,7 @@ export const SCHEMA_AWARE_TYPES = new Set<DatabaseType>([
"snowflake",
"trino",
"db2",
"tdengine",
]);
export const SQL_FILE_UNSUPPORTED_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch"]);
@@ -62,6 +63,7 @@ export const DATABASE_SEARCH_SUPPORTED_TYPES = new Set<DatabaseType>([
"bigquery",
"kylin",
"sundb",
"tdengine",
]);
export const TABLE_IMPORT_SUPPORTED_TYPES = new Set<DatabaseType>([
@@ -124,6 +126,7 @@ export const TREE_SCHEMA_TYPES = new Set<DatabaseType>([
"jdbc",
"trino",
"h2",
"tdengine",
]);
export const PG_LIKE_STRUCTURE_TYPES = new Set<DatabaseType>(["postgres", "redshift", "gaussdb"]);
@@ -147,6 +150,7 @@ export const AGENT_DRIVER_TYPES = new Set<DatabaseType>([
"kylin",
"sundb",
"gaussdb",
"tdengine",
]);
export const TRANSFER_SQL_TYPES = new Set<DatabaseType>([
+10
View File
@@ -8,6 +8,7 @@ export interface TableDataCapability {
updateRequiresPrimaryKey: boolean;
deleteRequiresPrimaryKey: boolean;
requiresTransactionalTableForExistingRows: boolean;
existingRowsReadonly?: boolean;
transaction: boolean;
readonly?: boolean;
}
@@ -77,6 +78,15 @@ const DATABASE_CAPABILITY_OVERRIDES: Partial<Record<DatabaseType, Partial<Databa
readonly: true,
},
},
tdengine: {
tableData: {
insert: true,
updateRequiresPrimaryKey: true,
deleteRequiresPrimaryKey: true,
requiresTransactionalTableForExistingRows: false,
transaction: false,
},
},
};
export function getDatabaseCapability(dbType?: DatabaseType): DatabaseCapability {
+16
View File
@@ -3,9 +3,11 @@ import { getDatabaseCapability } from "./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";
export function editablePrimaryKeys(databaseType: DatabaseType | undefined, columns: ColumnInfo[]): string[] {
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
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];
@@ -30,7 +32,10 @@ export function canEditExistingTableRows(
): 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;
}
@@ -63,3 +68,14 @@ export function isHiddenGridColumn(
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 ?? "");
}
+22 -3
View File
@@ -1,6 +1,6 @@
import type { DatabaseType } from "../types/database.ts";
import { isSchemaAware, usesFetchFirst } from "./databaseCapabilities.ts";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "./tableEditing.ts";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, DBX_TDENGINE_TBNAME_COLUMN } from "./tableEditing.ts";
export interface BuildTableSelectSqlOptions {
databaseType?: DatabaseType;
@@ -17,7 +17,8 @@ export interface BuildTableSelectSqlOptions {
}
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
if (databaseType === "mysql" || databaseType === "hive") return `\`${name.replace(/`/g, "``")}\``;
if (databaseType === "mysql" || databaseType === "hive" || databaseType === "tdengine")
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, "]]")}]`;
@@ -34,6 +35,7 @@ function isOracleRowId(databaseType: DatabaseType | undefined, name: string): bo
function quoteOrderIdentifier(databaseType: DatabaseType | undefined, name: string, tableAlias?: string): string {
if (isOracleRowId(databaseType, name)) return tableAlias ? `${tableAlias}.ROWID` : "ROWID";
if (isTdengineTbname(databaseType, name)) return DBX_TDENGINE_TBNAME_COLUMN;
return quoteTableIdentifier(databaseType, name);
}
@@ -90,7 +92,20 @@ export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string
}
function buildSelectColumns(databaseType: DatabaseType | undefined, columns?: string[]): string {
if (databaseType !== "hive" || !columns?.length) return "*";
if (!columns?.length) return "*";
if (databaseType === "tdengine") {
const tdengineColumns = columns.some((column) => column.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN)
? columns
: [DBX_TDENGINE_TBNAME_COLUMN, ...columns];
return tdengineColumns
.map((column) => {
if (isTdengineTbname(databaseType, column)) return DBX_TDENGINE_TBNAME_COLUMN;
const ident = quoteTableIdentifier(databaseType, column);
return `${ident} AS ${ident}`;
})
.join(", ");
}
if (databaseType !== "hive") return "*";
return columns
.map((column) => {
const ident = quoteTableIdentifier(databaseType, column);
@@ -99,6 +114,10 @@ function buildSelectColumns(databaseType: DatabaseType | undefined, columns?: st
.join(", ");
}
function isTdengineTbname(databaseType: DatabaseType | undefined, name: string): boolean {
return databaseType === "tdengine" && name.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN;
}
function buildNeo4jTableSelectSql(options: BuildTableSelectSqlOptions, limit: number): string {
const label = quoteCypherIdentifier(options.tableName);
const predicate = normalizeWhereInput(options.whereInput);
+2 -1
View File
@@ -14,6 +14,7 @@ import {
} from "@/lib/sqlAnalysis";
import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence";
import { mongoDocumentsToQueryResult, parseMongoFindCommand } from "@/lib/mongoShellCommand";
import { editablePrimaryKeys } from "@/lib/tableEditing";
import * as api from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { isTauriRuntime } from "@/lib/tauriRuntime";
@@ -364,7 +365,7 @@ export const useQueryStore = defineStore("query", () => {
try {
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
const primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name);
const primaryKeys = editablePrimaryKeys(dbType as DatabaseType, columns);
tab.tableMeta = {
schema: schema || undefined,
+1
View File
@@ -29,6 +29,7 @@ export type DatabaseType =
| "bigquery"
| "kylin"
| "sundb"
| "tdengine"
| "jdbc";
export interface ConnectionConfig {