From 1afc271c18500f257a44edfbe79c646c28086de4 Mon Sep 17 00:00:00 2001 From: John Jin Date: Sun, 14 Jun 2026 22:58:05 +0800 Subject: [PATCH] feat: add QuestDB support Thanks @ptma! --- README.md | 5 +- README.zh-CN.md | 5 +- .../desktop/public/icons/database/questdb.svg | 25 +++ .../connection/ConnectionDialog.vue | 5 +- .../src/components/icons/DatabaseIcon.vue | 1 + .../src/components/objects/ObjectBrowser.vue | 1 + .../structure/TableStructureEditor.vue | 8 +- .../desktop/src/lib/connectionPresentation.ts | 1 + apps/desktop/src/lib/connectionUrl.ts | 1 + .../desktop/src/lib/databaseCapabilitySets.ts | 6 +- .../src/lib/databaseObjectCapabilities.ts | 5 +- .../src/lib/databaseTableDataCapabilities.ts | 1 + apps/desktop/src/lib/databaseUserAdmin.ts | 2 +- apps/desktop/src/lib/dbeaverImport.ts | 1 + apps/desktop/src/lib/explainPlan.ts | 30 +++- .../src/lib/tableMetadataCapabilities.ts | 5 + .../src/lib/tableStructureCapabilities.ts | 22 +++ .../src/lib/tableStructureEditorState.ts | 30 +++- apps/desktop/src/lib/visibleDatabases.ts | 1 + apps/desktop/src/stores/connectionStore.ts | 3 + apps/desktop/src/types/database.ts | 1 + .../assets/database-drivers.manifest.json | 29 ++++ crates/dbx-core/src/connection.rs | 3 +- crates/dbx-core/src/data_grid_sql.rs | 1 + crates/dbx-core/src/db/mod.rs | 1 + crates/dbx-core/src/db/questdb.rs | 147 ++++++++++++++++++ crates/dbx-core/src/db_admin_sql.rs | 48 +++++- crates/dbx-core/src/models/connection.rs | 6 + crates/dbx-core/src/object_source_sql.rs | 15 +- crates/dbx-core/src/query_execution_sql.rs | 19 ++- crates/dbx-core/src/query_result_sql.rs | 18 ++- crates/dbx-core/src/schema.rs | 29 ++++ .../dbx-core/src/schema/providers/native.rs | 28 ++++ crates/dbx-core/src/sql_analysis.rs | 4 +- .../dbx-core/src/sql_dialect/identifiers.rs | 9 +- .../dbx-core/src/sql_dialect/table_select.rs | 35 +++++ .../src/table_structure_sql/column_alter.rs | 23 +++ .../src/table_structure_sql/column_format.rs | 27 ++++ .../src/table_structure_sql/columns.rs | 5 +- .../src/table_structure_sql/dialect.rs | 20 +++ .../dbx-core/src/table_structure_sql/util.rs | 4 +- crates/dbx-core/src/transfer.rs | 29 ++++ .../dbx-core/tests/database_capabilities.rs | 2 + docs/app/[lang]/databases/page.tsx | 1 + docs/app/[lang]/page.tsx | 1 + docs/content/docs/databases.cn.mdx | 3 +- docs/content/docs/databases.mdx | 5 +- .../app-tests/databaseCapabilities.test.ts | 2 +- packages/mcp-server/src/index.ts | 2 +- packages/node-core/src/diagnostics.ts | 2 +- src-tauri/src/commands/connection.rs | 2 + 51 files changed, 633 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/public/icons/database/questdb.svg create mode 100644 crates/dbx-core/src/db/questdb.rs diff --git a/README.md b/README.md index 9a6271adb..0e1ecd3a9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ +

@@ -87,7 +88,7 @@ ### 50+ Databases, One Tool -MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase, Databend, RQLite, Turso, InfluxDB, IoTDB, etcd, IRIS, and more. All in a single ~15 MB app. No bundled Chromium. +MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, IRIS, and more. All in a single ~15 MB app. No bundled Chromium. ### Query Editor @@ -324,7 +325,7 @@ DBX is 15 MB with no runtime dependencies (no Java, no Python). It includes AI a
What databases are supported? -MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend support to H2, Snowflake, Trino, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase, Databend, RQLite, Turso, InfluxDB, IoTDB, etcd, IRIS, and custom JDBC connections. +MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, and more. Agent/JDBC-oriented profiles extend support to H2, Snowflake, Trino, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, IRIS, and custom JDBC connections.
diff --git a/README.zh-CN.md b/README.zh-CN.md index 3610cae54..eb7b1d13f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,6 +37,7 @@ +

@@ -87,7 +88,7 @@ ### 50+ 种数据库,一个工具搞定 -MySQL、PostgreSQL、SQLite、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、IoTDB、etcd、IRIS 等。全部装进约 15 MB 的应用里,不内嵌 Chromium。 +MySQL、PostgreSQL、SQLite、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 15 MB 的应用里,不内嵌 Chromium。 ### 查询编辑器 @@ -320,7 +321,7 @@ DBX 仅 15 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和
支持哪些数据库? -MySQL、PostgreSQL、SQLite、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等。JDBC 方向配置可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、IoTDB、etcd、IRIS 及自定义 JDBC 连接。 +MySQL、PostgreSQL、SQLite、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo 等。JDBC 方向配置可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 及自定义 JDBC 连接。
diff --git a/apps/desktop/public/icons/database/questdb.svg b/apps/desktop/public/icons/database/questdb.svg new file mode 100644 index 000000000..48bff1499 --- /dev/null +++ b/apps/desktop/public/icons/database/questdb.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index a18720150..615505054 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -401,6 +401,7 @@ const driverProfiles: Record< }, gaussdb: { type: "gaussdb", port: 5432, user: "gaussdb", label: "GaussDB", icon: "gaussdb" }, kwdb: { type: "kwdb", port: 26257, user: "root", label: "KWDB", icon: "kwdb" }, + questdb: { type: "questdb", port: 8812, user: "questdb", label: "QuestDB", icon: "questdb" }, kingbase: { type: "kingbase", port: 54321, user: "system", label: "KingBase", icon: "kingbase" }, highgo: { type: "highgo", port: 5866, user: "highgo", label: "瀚高 HighGo", icon: "highgo" }, yashandb: { type: "yashandb", port: 1688, user: "sys", label: "崖山 YashanDB", icon: "yashandb" }, @@ -737,6 +738,7 @@ const iconTypeMap: Record = { opengauss: "opengauss", gaussdb: "gaussdb", kwdb: "kwdb", + questdb: "questdb", kingbase: "kingbase", highgo: "highgo", yashandb: "yashandb", @@ -789,6 +791,7 @@ const dbOptions: DbOption[] = [ { value: "clickhouse", label: "ClickHouse" }, { value: "gaussdb", label: "GaussDB" }, { value: "kwdb", label: "KWDB" }, + { value: "questdb", label: "QuestDB" }, { value: "tidb", label: "TiDB" }, { value: "oceanbase", label: "OceanBase" }, { value: "goldendb", label: "GoldenDB" }, @@ -878,7 +881,7 @@ const sqliteExtensionPaths = computed({ form.value.url_params = setSqliteExtensionPaths(form.value.url_params, value); }, }); -const tlsCapableDatabaseTypes = new Set(["mysql", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "redis", "etcd", "clickhouse", "elasticsearch", "influxdb"]); +const tlsCapableDatabaseTypes = new Set(["mysql", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "redis", "etcd", "clickhouse", "elasticsearch", "influxdb"]); const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type)); const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse"); const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch"); diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue index 469593e6c..d81ba5d6f 100644 --- a/apps/desktop/src/components/icons/DatabaseIcon.vue +++ b/apps/desktop/src/components/icons/DatabaseIcon.vue @@ -30,6 +30,7 @@ const assetIcons: Record = { oceanbase: "oceanbase", opengauss: "opengauss", gaussdb: "gaussdb", + questdb: "questdb", kwdb: "kwdb", kingbase: "kingbase", highgo: "highgo.png", diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 9a713e666..921aedb2d 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -167,6 +167,7 @@ const sourceFormatDialect = computed(() => { case "gaussdb": case "kwdb": case "opengauss": + case "questdb": return "postgres"; default: return "generic"; diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index ada084f44..ca1b84311 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -37,6 +37,7 @@ import { getColumnEditorControls, getDataTypeOptions, getDefaultLengthForType, + isDataTypeLengthDisabled, isProtectedManticoreIdColumn, splitDataType, toColumnNames, @@ -509,10 +510,11 @@ function isColumnTypeDisabled(column: EditableStructureColumn): boolean { } function isColumnLengthDisabled(column: EditableStructureColumn): boolean { - if (isColumnTypeDisabled(column)) return true; - if (databaseType.value !== "manticoresearch") return false; + if (isColumnTypeDisabled(column)) { + return true; + } const baseType = splitDataType(column.dataType).baseType.trim().toLowerCase(); - return baseType !== "bit" && baseType !== "float_vector"; + return isDataTypeLengthDisabled(databaseType.value, baseType); } function isColumnNullableDisabled(column: EditableStructureColumn): boolean { diff --git a/apps/desktop/src/lib/connectionPresentation.ts b/apps/desktop/src/lib/connectionPresentation.ts index 1912e6c8e..c876878e6 100644 --- a/apps/desktop/src/lib/connectionPresentation.ts +++ b/apps/desktop/src/lib/connectionPresentation.ts @@ -90,6 +90,7 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string { case "kwdb": case "yashandb": case "redshift": + case "questdb": return "postgresql://user:password@host:port/database"; case "redis": diff --git a/apps/desktop/src/lib/connectionUrl.ts b/apps/desktop/src/lib/connectionUrl.ts index 1ab19491e..7c4454317 100644 --- a/apps/desktop/src/lib/connectionUrl.ts +++ b/apps/desktop/src/lib/connectionUrl.ts @@ -48,6 +48,7 @@ const SCHEME_PROFILES: Record = { "informix-sqli": { type: "informix", profile: "informix", label: "Informix", defaultPort: 9088 }, yashandb: { type: "yashandb", profile: "yashandb", label: "YashanDB", defaultPort: 1688 }, opengauss: { type: "gaussdb", profile: "opengauss", label: "openGauss", defaultPort: 5432 }, + questdb: { type: "questdb", profile: "questdb", label: "QuestDB", defaultPort: 8812 }, tdengine: { type: "tdengine", profile: "tdengine", label: "TDengine", defaultPort: 6041 }, "taos-ws": { type: "tdengine", profile: "tdengine", label: "TDengine", defaultPort: 6041 }, xugu: { type: "xugu", profile: "xugu", label: "XuguDB", defaultPort: 5138 }, diff --git a/apps/desktop/src/lib/databaseCapabilitySets.ts b/apps/desktop/src/lib/databaseCapabilitySets.ts index 65b82042e..34ce27f2c 100644 --- a/apps/desktop/src/lib/databaseCapabilitySets.ts +++ b/apps/desktop/src/lib/databaseCapabilitySets.ts @@ -35,7 +35,7 @@ export const SCHEMA_AWARE_TYPES = new Set([ "duckdb", ]); -export const SINGLE_DATABASE_TYPES = new Set(["oracle", "dameng", "firebird", "oceanbase-oracle", "access"]); +export const SINGLE_DATABASE_TYPES = new Set(["oracle", "dameng", "firebird", "oceanbase-oracle", "access", "questdb"]); export const FETCH_FIRST_TYPES = new Set(["oracle", "dameng"]); @@ -71,6 +71,6 @@ export const TREE_SCHEMA_TYPES = new Set([ export const DATABASE_OBJECT_TREE_TYPES = new Set(["jdbc"]); -export const PG_LIKE_STRUCTURE_TYPES = new Set(["postgres", "redshift", "gaussdb", "kwdb", "opengauss"]); +export const PG_LIKE_STRUCTURE_TYPES = new Set(["postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb"]); -export const DIAGRAM_SQL_TYPES = new Set(["mysql", "postgres", "sqlite", "rqlite", "turso", "sqlserver", "oracle", "redshift", "dameng", "gaussdb", "kwdb", "opengauss", "oceanbase-oracle"]); +export const DIAGRAM_SQL_TYPES = new Set(["mysql", "postgres", "sqlite", "rqlite", "turso", "sqlserver", "oracle", "redshift", "dameng", "gaussdb", "kwdb", "opengauss", "questdb", "oceanbase-oracle"]); diff --git a/apps/desktop/src/lib/databaseObjectCapabilities.ts b/apps/desktop/src/lib/databaseObjectCapabilities.ts index d25cd5dd7..1b2edd8e0 100644 --- a/apps/desktop/src/lib/databaseObjectCapabilities.ts +++ b/apps/desktop/src/lib/databaseObjectCapabilities.ts @@ -8,14 +8,16 @@ export interface DatabaseObjectCapabilities { executable: SidebarObjectKind[]; } +const TABLE_OBJECTS: SidebarObjectKind[] = ["TABLE"]; const TABLE_VIEW_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW"]; const TABLE_FUNCTION_OBJECTS: SidebarObjectKind[] = ["TABLE", "FUNCTION"]; const ROUTINE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION"]; const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"]; const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"]; +const TABLE_ONLY_TYPES = new Set(["influxdb"]); const TABLE_FUNCTION_TYPES = new Set(["manticoresearch"]); -const TABLE_VIEW_ONLY_TYPES = new Set(["sqlite", "rqlite", "turso", "duckdb", "clickhouse", "doris", "starrocks", "databend", "hive", "trino", "cassandra", "bigquery", "kylin", "tdengine", "iotdb", "neo4j"]); +const TABLE_VIEW_ONLY_TYPES = new Set(["sqlite", "rqlite", "turso", "duckdb", "clickhouse", "doris", "starrocks", "databend", "hive", "trino", "cassandra", "bigquery", "kylin", "tdengine", "iotdb", "neo4j", "questdb"]); const ORACLE_PACKAGE_TYPES = new Set(["oracle", "oceanbase-oracle"]); const POSTGRES_SEQUENCE_TYPES = new Set(["postgres", "gaussdb", "kwdb", "opengauss"]); @@ -32,6 +34,7 @@ export function databaseObjectCapabilities(dbType?: DatabaseType): DatabaseObjec export function sidebarObjectKindsForDatabase(dbType?: DatabaseType): SidebarObjectKind[] { if (!dbType) return [...TABLE_VIEW_OBJECTS]; if (ORACLE_PACKAGE_TYPES.has(dbType)) return [...ORACLE_OBJECTS]; + if (TABLE_ONLY_TYPES.has(dbType)) return [...TABLE_OBJECTS]; if (TABLE_FUNCTION_TYPES.has(dbType)) return [...TABLE_FUNCTION_OBJECTS]; if (TABLE_VIEW_ONLY_TYPES.has(dbType)) return [...TABLE_VIEW_OBJECTS]; if (POSTGRES_SEQUENCE_TYPES.has(dbType)) return [...POSTGRES_OBJECTS]; diff --git a/apps/desktop/src/lib/databaseTableDataCapabilities.ts b/apps/desktop/src/lib/databaseTableDataCapabilities.ts index 72b1e42ba..ca51f80f5 100644 --- a/apps/desktop/src/lib/databaseTableDataCapabilities.ts +++ b/apps/desktop/src/lib/databaseTableDataCapabilities.ts @@ -73,6 +73,7 @@ const NAVICAT_STYLE_TABLE_DATA_TYPES = new Set([ "firebird", "exasol", "opengauss", + "questdb", "oceanbase-oracle", "gbase", "access", diff --git a/apps/desktop/src/lib/databaseUserAdmin.ts b/apps/desktop/src/lib/databaseUserAdmin.ts index 8d9cf6271..3bf119d1d 100644 --- a/apps/desktop/src/lib/databaseUserAdmin.ts +++ b/apps/desktop/src/lib/databaseUserAdmin.ts @@ -46,7 +46,7 @@ export interface DatabaseUserAdminProvider { } export const MYSQL_USER_ADMIN_TYPES = new Set(["mysql", "goldendb"]); -export const POSTGRES_USER_ADMIN_TYPES = new Set(["postgres", "gaussdb", "highgo", "kingbase", "kwdb", "opengauss", "vastbase"]); +export const POSTGRES_USER_ADMIN_TYPES = new Set(["postgres", "gaussdb", "highgo", "kingbase", "kwdb", "opengauss", "questdb", "vastbase"]); export const MYSQL_COMMON_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "EXECUTE", "SHOW VIEW", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES"] as const; diff --git a/apps/desktop/src/lib/dbeaverImport.ts b/apps/desktop/src/lib/dbeaverImport.ts index 279d61e39..818cd9b67 100644 --- a/apps/desktop/src/lib/dbeaverImport.ts +++ b/apps/desktop/src/lib/dbeaverImport.ts @@ -50,6 +50,7 @@ const profileMap: Record = { gaussdb: { dbType: "gaussdb", profile: "gaussdb", label: "GaussDB", port: 5432, user: "gaussdb" }, kwdb: { dbType: "kwdb", profile: "kwdb", label: "KWDB", port: 26257, user: "root" }, opengauss: { dbType: "gaussdb", profile: "opengauss", label: "openGauss", port: 5432, user: "gaussdb" }, + questdb: { dbType: "questdb", profile: "questdb", label: "QuestDB", port: 8812, user: "questdb" }, influxdb: { dbType: "influxdb", profile: "influxdb", label: "InfluxDB", port: 8086, user: "" }, }; diff --git a/apps/desktop/src/lib/explainPlan.ts b/apps/desktop/src/lib/explainPlan.ts index cdc5030aa..ee96060a5 100644 --- a/apps/desktop/src/lib/explainPlan.ts +++ b/apps/desktop/src/lib/explainPlan.ts @@ -16,15 +16,15 @@ export interface ExplainPlanNode { } export interface ParsedExplainPlan { - databaseType: "mysql" | "postgres" | "dameng"; + databaseType: "mysql" | "postgres" | "dameng" | "questdb"; raw: unknown; nodes: ExplainPlanNode[]; } export type BuildExplainSqlResult = { ok: true; sql: string } | { ok: false; reason: "unsupported" | "empty" | "unsafe" }; -const SUPPORTED_EXPLAIN_TYPES = new Set(["mysql", "postgres", "dameng"]); -export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" { +const SUPPORTED_EXPLAIN_TYPES = new Set(["mysql", "postgres", "dameng", "questdb"]); +export function supportsExplainPlan(databaseType?: DatabaseType): databaseType is "mysql" | "postgres" | "dameng" | "questdb" { return !!databaseType && supportsDatabaseFeature(databaseType, "sqlExplain") && SUPPORTED_EXPLAIN_TYPES.has(databaseType); } @@ -32,9 +32,11 @@ export function buildExplainSql(databaseType: DatabaseType | undefined, sql: str return api.buildExplainSql({ databaseType, sql }) as Promise; } -export function parseExplainResult(databaseType: "mysql" | "postgres" | "dameng", result: QueryResult): ParsedExplainPlan { +export function parseExplainResult(databaseType: "mysql" | "postgres" | "dameng" | "questdb", result: QueryResult): ParsedExplainPlan { if (databaseType === "dameng") { return parseDamengExplain(result); + } else if (databaseType === "questdb") { + return parseQuestdbExplain(result); } const raw = parseExplainCell(result.rows[0]?.[0]); const nodes = databaseType === "postgres" ? parsePostgresExplain(raw) : parseMysqlExplain(raw); @@ -511,6 +513,26 @@ function parseMysqlTable(table: Record, id: string): ExplainPla }; } +// ── QuestDB explain parser ───────────────────────────────────────── +function parseQuestdbExplain(result: QueryResult): ParsedExplainPlan { + let nodes: ExplainPlanNode[] = result.rows.map((row: unknown[], index: number) => { + const text = String(row[0] ?? ""); + const node: ExplainPlanNode = { + id: `plan_${index}`, + title: text, + nodeType: "Plan", + relation: "", + index: String(index), + cost: undefined, + rows: undefined, + details: [text], + children: [], + }; + return node; + }); + return { databaseType: "questdb", raw: result, nodes }; +} + // ── Helpers ─────────────────────────────────────────────────────────── function objectValue(value: unknown): Record | null { diff --git a/apps/desktop/src/lib/tableMetadataCapabilities.ts b/apps/desktop/src/lib/tableMetadataCapabilities.ts index b2843f451..be51a3803 100644 --- a/apps/desktop/src/lib/tableMetadataCapabilities.ts +++ b/apps/desktop/src/lib/tableMetadataCapabilities.ts @@ -36,6 +36,11 @@ const capabilityByType: Partial> = { mysql: mysqlCapabilities, doris: mysqlCapabilities, @@ -256,6 +277,7 @@ const capabilityByType: Partial gaussdb: postgresCapabilities, kwdb: postgresCapabilities, opengauss: postgresCapabilities, + questdb: questdbCapabilities, redshift: redshiftCapabilities, vertica: redshiftCapabilities, highgo: postgresCapabilities, diff --git a/apps/desktop/src/lib/tableStructureEditorState.ts b/apps/desktop/src/lib/tableStructureEditorState.ts index 42819eda6..3477bfd15 100644 --- a/apps/desktop/src/lib/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/tableStructureEditorState.ts @@ -267,6 +267,7 @@ export const DATA_TYPE_OPTIONS: Record = { "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"], }; const DATA_TYPE_OPTION_ALIASES: Partial> = { @@ -278,6 +279,7 @@ const DATA_TYPE_OPTION_ALIASES: Partial> = { gaussdb: "postgres", kwdb: "postgres", opengauss: "postgres", + questdb: "questdb", redshift: "postgres", highgo: "postgres", vastbase: "postgres", @@ -365,6 +367,13 @@ export const DEFAULT_TYPE_LENGTHS: Record = { year: "4", }; +export const QUESTDB_TYPE_LENGTHS: Record = { + geohash: "8c", + decimal: "10,2", +}; + +export const DEFAULT_TYPE_LENGTH_DISABLES: string[] = []; + export function parseExtraToColumnExtra(extra: string | null | undefined, databaseType?: DatabaseType): ColumnExtra { const result: ColumnExtra = {}; if (!extra) return result; @@ -378,7 +387,7 @@ export function parseExtraToColumnExtra(extra: string | null | undefined, databa if (lower.includes("on update current_timestamp")) { result.onUpdateCurrentTimestamp = true; } - } else if (databaseType === "postgres" || databaseType === "gaussdb" || databaseType === "kwdb" || databaseType === "opengauss" || databaseType === "highgo" || databaseType === "vastbase" || databaseType === "kingbase") { + } 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); @@ -635,6 +644,8 @@ function isTemporalPrecisionType(dbType: DatabaseType | undefined, baseType: str 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; } @@ -649,7 +660,22 @@ function isValidTemporalPrecision(dbType: DatabaseType | undefined, params: stri export function getDefaultLengthForType(_dbType: DatabaseType | undefined, baseType: string): string { const key = baseType.trim().toLowerCase(); - return DEFAULT_TYPE_LENGTHS[key] ?? ""; + 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 { + return DEFAULT_TYPE_LENGTH_DISABLES.includes(key); + } } export function buildStructureTargetLabel(connectionName: string | undefined, database: string | undefined, schema: string | undefined, tableName: string | undefined): string { diff --git a/apps/desktop/src/lib/visibleDatabases.ts b/apps/desktop/src/lib/visibleDatabases.ts index 930009a51..72c0b337d 100644 --- a/apps/desktop/src/lib/visibleDatabases.ts +++ b/apps/desktop/src/lib/visibleDatabases.ts @@ -11,6 +11,7 @@ const SYSTEM_DATABASE_RULES: Partial>> gaussdb: new Set(["template0", "template1"]), kwdb: new Set(["template0", "template1"]), opengauss: new Set(["template0", "template1"]), + questdb: new Set(["template0", "template1"]), kingbase: new Set(["template0", "template1"]), highgo: new Set(["template0", "template1"]), vastbase: new Set(["template0", "template1"]), diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d66e93a35..bfb788856 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -251,6 +251,7 @@ export const useConnectionStore = defineStore("connection", () => { redshift: "Redshift", dameng: "DM (Dameng)", gaussdb: "GaussDB", + questdb: "QuestDB", kwdb: "KWDB", kingbase: "KingBase", highgo: "瀚高 HighGo", @@ -278,6 +279,8 @@ export const useConnectionStore = defineStore("connection", () => { dbType = "gaussdb" as ConnectionConfig["db_type"]; } else if (profile === "kwdb" && dbType === "postgres") { dbType = "kwdb" as ConnectionConfig["db_type"]; + } else if (profile === "questdb" && dbType === "postgres") { + dbType = "questdb" as ConnectionConfig["db_type"]; } else if (profile === "redshift" && dbType === "postgres") { dbType = "redshift" as ConnectionConfig["db_type"]; } else if (profile === "kingbase" && dbType === "postgres") { diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 6e627117d..6c5bf1178 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -32,6 +32,7 @@ export type DatabaseType = | "exasol" | "opengauss" | "oceanbase-oracle" + | "questdb" | "gbase" | "access" | "h2" diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index ce2ede2c4..0dc4302fd 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -913,6 +913,35 @@ "driverManagement": true } }, + { + "dbType": "questdb", + "label": "QuestDB", + "runtimeMode": "native", + "mcpMode": "direct", + "singleConnectionPool": false, + "metadataConnectionScoped": false, + "skipTcpProbe": false, + "defaultPort": 8812, + "supportLevel": "operate", + "capabilities": { + "queryExecution": true, + "metadataBrowse": true, + "objectBrowser": true, + "objectSource": true, + "schemaSearch": true, + "diagram": false, + "tableDataEdit": true, + "tableStructureEdit": true, + "tableImport": true, + "dataTransfer": true, + "sqlFileExecution": true, + "databaseCreate": false, + "fieldLineage": false, + "sqlExplain": true, + "userAdmin": false, + "driverManagement": true + } + }, { "dbType": "oceanbase-oracle", "label": "OceanBase Oracle Mode", diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index c17c6aad7..9ff5d16b6 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -371,6 +371,7 @@ impl AppState { | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::Kwdb + | DatabaseType::Questdb | DatabaseType::OpenGauss => PoolKind::Postgres(db::postgres::connect(&url, connect_timeout).await?), DatabaseType::Sqlite => { let extensions = db::sqlite::sqlite_extension_specs_from_url_params(db_config.url_params.as_deref()) @@ -1154,7 +1155,7 @@ fn external_driver_connect_timeout(config: &ConnectionConfig) -> std::time::Dura fn native_postgres_url_config(config: &ConnectionConfig) -> Option { match config.db_type { - DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss => { + DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss | DatabaseType::Questdb => { let mut normalized = config.clone(); normalized.database = normalized.effective_database().map(str::to_string); if matches!(config.db_type, DatabaseType::Gaussdb | DatabaseType::Kwdb) { diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index c97fb6d68..c8e29ddb1 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -1318,6 +1318,7 @@ fn uses_keyless_row_predicate(database_type: Option) -> bool { | DatabaseType::Firebird | DatabaseType::Exasol | DatabaseType::OpenGauss + | DatabaseType::Questdb | DatabaseType::OceanbaseOracle | DatabaseType::Gbase | DatabaseType::Access diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index e87636f69..4f73ce85c 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -11,6 +11,7 @@ pub mod mysql; pub mod ob_oracle; pub mod postgres; pub mod proxy_tunnel; +pub mod questdb; pub mod redis_driver; pub mod rqlite_driver; pub mod sqlite; diff --git a/crates/dbx-core/src/db/questdb.rs b/crates/dbx-core/src/db/questdb.rs new file mode 100644 index 000000000..11d974f72 --- /dev/null +++ b/crates/dbx-core/src/db/questdb.rs @@ -0,0 +1,147 @@ +use crate::db; +use crate::db::{ColumnInfo, IndexInfo, ObjectInfo, QueryResult, TableInfo}; +use crate::models::connection::DatabaseType; +use crate::sql_dialect::quote_table_identifier; +use deadpool_postgres::Pool; + +pub async fn list_objects(pool: &Pool, schema: &str) -> Result, String> { + Ok(list_tables(pool, schema) + .await? + .iter() + .map(|t| ObjectInfo { + name: t.name.clone(), + object_type: t.table_type.clone(), + schema: None, + comment: t.comment.clone(), + created_at: None, + updated_at: None, + parent_schema: None, + parent_name: None, + }) + .collect()) +} + +pub async fn list_tables(pool: &Pool, _schema: &str) -> Result, String> { + let client = pool.get().await.map_err(|e| e.to_string())?; + + let stmt = client.prepare_cached(questdb_tables_sql()).await.map_err(|e| e.to_string())?; + let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?; + + Ok(rows + .iter() + .map(|row| { + let table_type_col = row.get::<_, String>(1); + let table_type = if table_type_col.eq_ignore_ascii_case("T") { "TABLE" } else { "VIEW" }; + let comment = + if table_type_col.eq_ignore_ascii_case("M") { Some("Materialized".to_string()) } else { None }; + TableInfo { + name: row.get::<_, String>(0), + table_type: table_type.to_string(), + comment, + parent_schema: None, + parent_name: None, + } + }) + .collect()) +} + +fn questdb_tables_sql() -> &'static str { + "SELECT table_name, table_type FROM tables" +} + +pub async fn get_columns(pool: &Pool, _schema: &str, table: &str) -> Result, String> { + let client = pool.get().await.map_err(|e| e.to_string())?; + let sql = format!("SHOW COLUMNS FROM {}", quote_table_identifier(Some(DatabaseType::Questdb), table)); + let stmt = client.prepare_cached(&sql).await.map_err(|e| e.to_string())?; + let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?; + + let not_null_types: [&str; 3] = ["boolean", "byte", "short"]; + + Ok(rows + .iter() + .map(|row| { + let column_type = row.get::<_, String>(1); + ColumnInfo { + name: row.get::<_, String>(0), + data_type: column_type.clone().to_lowercase(), + is_nullable: !not_null_types.contains(&column_type.as_str()), + column_default: None, + is_primary_key: column_type.eq_ignore_ascii_case("timestamp") + || column_type.eq_ignore_ascii_case("symbol"), + extra: None, + comment: None, + numeric_precision: None, + numeric_scale: None, + character_maximum_length: None, + } + }) + .collect()) +} + +pub async fn list_indexes(pool: &Pool, _schema: &str, table: &str) -> Result, String> { + let client = pool.get().await.map_err(|e| e.to_string())?; + let sql = format!("SHOW COLUMNS FROM {}", quote_table_identifier(Some(DatabaseType::Questdb), table)); + let stmt = client.prepare_cached(&sql).await.map_err(|e| e.to_string())?; + let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?; + + Ok(rows + .iter() + .filter(|r| r.get::<_, bool>(2)) + .map(|row| { + let column: String = row.get::<_, String>(0); + let key_cols = vec![column]; + IndexInfo { + name: row.get::<_, String>(0), + columns: key_cols, + is_unique: false, + is_primary: true, + filter: None, + index_type: None, + included_columns: None, + comment: None, + } + }) + .collect()) +} + +pub async fn questdb_object_source(pool: &Pool, name: &str) -> Result { + questdb_view_ddl(pool, name).await.map_err(|e| e.to_string()) +} + +pub async fn questdb_table_or_view_ddl(pool: &Pool, table_or_view: &str) -> Result { + match questdb_table_ddl(pool, table_or_view).await { + Ok(ddl) => Ok(ddl), + Err(_) => questdb_view_ddl(pool, table_or_view).await, + } +} + +async fn questdb_table_ddl(pool: &Pool, table: &str) -> Result { + let sql = format!("SHOW CREATE TABLE {}", quote_table_identifier(Some(DatabaseType::Questdb), table)); + first_string_cell(db::postgres::execute_query(pool, &sql).await?) +} + +async fn questdb_view_ddl(pool: &Pool, view: &str) -> Result { + match questdb_mat_view_ddl(pool, view).await { + Ok(ddl) => Ok(ddl), + Err(_) => questdb_normal_view_ddl(pool, view).await, + } +} + +async fn questdb_mat_view_ddl(pool: &Pool, view: &str) -> Result { + let sql = format!("SHOW CREATE MATERIALIZED VIEW {}", quote_table_identifier(Some(DatabaseType::Questdb), view)); + first_string_cell(db::postgres::execute_query(pool, &sql).await?) +} + +async fn questdb_normal_view_ddl(pool: &Pool, view: &str) -> Result { + let sql = format!("SHOW CREATE VIEW {}", quote_table_identifier(Some(DatabaseType::Questdb), view)); + first_string_cell(db::postgres::execute_query(pool, &sql).await?) +} + +fn first_string_cell(result: QueryResult) -> Result { + result + .rows + .first() + .and_then(|row| row.iter().find_map(|value| value.as_str().map(str::to_string))) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Object source not found".to_string()) +} diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index fe14dfe7c..80d14cc39 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -193,6 +193,7 @@ pub fn build_drop_table_child_object_sql(options: DropTableChildObjectSqlOptions | DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss + | DatabaseType::Questdb | DatabaseType::Highgo | DatabaseType::Vastbase | DatabaseType::Kingbase @@ -223,6 +224,7 @@ pub fn build_drop_table_child_object_sql(options: DropTableChildObjectSqlOptions | DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss + | DatabaseType::Questdb | DatabaseType::Highgo | DatabaseType::Vastbase | DatabaseType::Kingbase @@ -249,7 +251,9 @@ pub fn build_empty_table_sql(options: TableAdminSqlOptions) -> String { match options.database_type { Some(DatabaseType::ClickHouse) => format!("ALTER TABLE {table} DELETE WHERE 1 = 1;"), Some(DatabaseType::Bigquery) => format!("DELETE FROM {table} WHERE TRUE;"), - Some(DatabaseType::Cassandra | DatabaseType::Hive | DatabaseType::Kylin) => format!("TRUNCATE TABLE {table};"), + Some(DatabaseType::Cassandra | DatabaseType::Hive | DatabaseType::Kylin | DatabaseType::Questdb) => { + format!("TRUNCATE TABLE {table};") + } Some(DatabaseType::Iotdb) => format!("DELETE FROM {};", iotdb_timeseries_pattern(&table)), _ => format!("DELETE FROM {table};"), } @@ -289,6 +293,9 @@ pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOp if options.database_type == Some(DatabaseType::Mysql) { return format!("CREATE TABLE {target} LIKE {source};"); } + if options.database_type == Some(DatabaseType::Questdb) { + return format!("CREATE TABLE {target} (LIKE {source});"); + } if options.database_type.is_some_and(is_postgres_like_structure_copy) { return format!("CREATE TABLE {target} (LIKE {source} INCLUDING ALL);"); } @@ -400,6 +407,7 @@ fn is_postgres_like_structure_copy(database_type: DatabaseType) -> bool { | DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss + | DatabaseType::Questdb ) } @@ -607,6 +615,23 @@ mod tests { }), "DELETE FROM root.test.DCU_101.*;" ); + + assert_eq!( + build_empty_table_sql(TableAdminSqlOptions { + database_type: Some(DatabaseType::Questdb), + schema: None, + table_name: "table_sample".to_string(), + }), + "TRUNCATE TABLE `table_sample`;" + ); + assert_eq!( + build_truncate_table_sql(TableAdminSqlOptions { + database_type: Some(DatabaseType::Questdb), + schema: None, + table_name: "table_sample".to_string(), + }), + "TRUNCATE TABLE `table_sample`;" + ); } #[test] @@ -711,6 +736,18 @@ mod tests { .unwrap(), "DROP TRIGGER \"orders_audit\" ON \"public\".\"orders\";" ); + + assert_eq!( + build_drop_table_child_object_sql(DropTableChildObjectSqlOptions { + database_type: Some(DatabaseType::Questdb), + object_type: TableChildObjectType::Column, + schema: Some("public".to_string()), + table_name: "orders".to_string(), + name: "status".to_string(), + }) + .unwrap(), + "ALTER TABLE `orders` DROP COLUMN `status`;" + ); } #[test] @@ -751,6 +788,15 @@ mod tests { }), "CREATE TABLE \"HR\".\"USERS_COPY\" AS SELECT * FROM \"HR\".\"USERS\" WHERE 1=0" ); + assert_eq!( + build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { + database_type: Some(DatabaseType::Questdb), + schema: None, + source_name: "users".to_string(), + target_name: "users_copy".to_string(), + }), + "CREATE TABLE `users_copy` (LIKE `users`);" + ); } #[test] diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 7a3037c8c..723bdf837 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -292,6 +292,8 @@ pub enum DatabaseType { Turso, #[serde(rename = "influxdb")] InfluxDb, + #[serde(rename = "questdb")] + Questdb, Jdbc, } @@ -725,6 +727,7 @@ impl ConnectionConfig { format!("{base}?{params}") } } + DatabaseType::Questdb => format!("questdb://{host}:{port}{db_part}"), DatabaseType::Gbase => format!("gbase://{host}:{port}{db_part}"), DatabaseType::H2 => format!("h2://{host}:{port}{db_part}"), DatabaseType::Snowflake => format!("snowflake://{host}/{db_part}"), @@ -886,6 +889,9 @@ impl ConnectionConfig { format!("{base}?{params}") } } + DatabaseType::Questdb => { + format!("questdb://{}:{}@{host}:{port}{db_part}", username, password) + } DatabaseType::Gbase => { format!("gbase://{}:{}@{host}:{port}{db_part}", username, password) } diff --git a/crates/dbx-core/src/object_source_sql.rs b/crates/dbx-core/src/object_source_sql.rs index cf997cad6..48de13060 100644 --- a/crates/dbx-core/src/object_source_sql.rs +++ b/crates/dbx-core/src/object_source_sql.rs @@ -127,7 +127,11 @@ pub fn build_executable_object_source_statements(input: EditableObjectSourceSqlI if matches!( input.database_type, - DatabaseType::Postgres | DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss + DatabaseType::Postgres + | DatabaseType::Gaussdb + | DatabaseType::Kwdb + | DatabaseType::OpenGauss + | DatabaseType::Questdb ) && input.object_type == ObjectSourceKind::View { return Ok(vec![format!( @@ -159,9 +163,11 @@ pub fn build_view_ddl_sql(input: BuildViewDdlInput) -> String { }; if input.database_type.is_none() - || input - .database_type - .is_some_and(|database_type| is_postgres_like(database_type) || database_type == DatabaseType::OpenGauss) + || input.database_type.is_some_and(|database_type| { + is_postgres_like(database_type) + || database_type == DatabaseType::OpenGauss + || database_type == DatabaseType::Questdb + }) { return format!("CREATE OR REPLACE VIEW {qualified_name} AS\n{}", ensure_semicolon(source)); } @@ -215,6 +221,7 @@ fn is_postgres_like(database_type: DatabaseType) -> bool { | DatabaseType::Gaussdb | DatabaseType::Kwdb | DatabaseType::OpenGauss + | DatabaseType::Questdb | DatabaseType::Kingbase | DatabaseType::Highgo | DatabaseType::Vastbase diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs index f1f307ff6..e5bc71ff9 100644 --- a/crates/dbx-core/src/query_execution_sql.rs +++ b/crates/dbx-core/src/query_execution_sql.rs @@ -41,12 +41,14 @@ pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult { return explain_err("unsafe"); } - let sql = if options.database_type == Some(DatabaseType::Postgres) { - format!("EXPLAIN (FORMAT JSON) {source}") - } else if options.database_type == Some(DatabaseType::Dameng) { - format!("EXPLAIN {source}") - } else { - format!("EXPLAIN FORMAT=JSON {source}") + let sql = match options.database_type { + Some(DatabaseType::Postgres | DatabaseType::MongoDb) => { + format!("EXPLAIN (FORMAT JSON) {source}") + } + Some(DatabaseType::Dameng | DatabaseType::Questdb) => { + format!("EXPLAIN {source}") + } + _ => format!("EXPLAIN FORMAT=JSON {source}"), }; ExplainSqlBuildResult { ok: true, sql: Some(sql), reason: None } } @@ -71,7 +73,10 @@ pub fn build_dropped_file_preview_sql(options: DroppedFilePreviewSqlOptions) -> } pub fn supports_explain_plan(database_type: Option) -> bool { - matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Postgres | DatabaseType::Dameng)) + matches!( + database_type, + Some(DatabaseType::Mysql | DatabaseType::Postgres | DatabaseType::Questdb | DatabaseType::Dameng) + ) } pub fn is_safe_dameng_autotrace_sql(sql: &str) -> bool { diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index d4b7a5567..de190bd1a 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -7,7 +7,7 @@ use crate::sql::find_statement_at_cursor; use crate::sql_dialect::{quote_table_identifier, uses_fetch_first}; static LIMIT_OFFSET_STRIP_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)(\s+LIMIT\s+\d+(\s+OFFSET\s+\d+)?|\s+OFFSET\s+\d+(\s+LIMIT\s+\d+)?|\s+OFFSET\s+\d+\s+ROWS?\s+FETCH\s+(?:FIRST|NEXT)\s+\d+\s+ROWS?\s+ONLY|\s+FETCH\s+(?:FIRST|NEXT)\s+\d+\s+ROWS?\s+ONLY)\s*$").unwrap() + Regex::new(r"(?i)(\s+LIMIT\s+\d+(\s+OFFSET\s+\d+)?|\s+LIMIT\s+\d+(\s*,\s*\d+)?|\s+OFFSET\s+\d+(\s+LIMIT\s+\d+)?|\s+OFFSET\s+\d+\s+ROWS?\s+FETCH\s+(?:FIRST|NEXT)\s+\d+\s+ROWS?\s+ONLY|\s+FETCH\s+(?:FIRST|NEXT)\s+\d+\s+ROWS?\s+ONLY)\s*$").unwrap() }); #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -178,6 +178,10 @@ pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlB return ok(add_mysql_limit(&statement, safe_limit, safe_offset)); } + if options.database_type == Some(DatabaseType::Questdb) { + return ok(add_questdb_limit(&statement, safe_limit, safe_offset)); + } + if options.database_type == Some(DatabaseType::Elasticsearch) { // If the user wrote their own LIMIT, leave the SQL alone — they // explicitly bounded the result set and the front-end will paginate @@ -431,6 +435,18 @@ fn add_informix_first_limit(statement: &str, limit: usize, offset: usize) -> Str format!("SELECT {row_limit} * FROM ({statement}) dbx_page;") } +fn add_questdb_limit(statement: &str, limit: usize, offset: usize) -> String { + if has_top_level_limit(statement) { + return format!("{statement};"); + } + if offset > 0 { + let upper_bound = offset + limit; + format!("{statement} LIMIT {offset}, {upper_bound};") + } else { + format!("{statement} LIMIT {limit};") + } +} + fn has_top_level_limit(sql: &str) -> bool { top_level_sql_tokens(sql).iter().any(|token| token.text == "LIMIT") } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 859e1a7a7..82c7d5dca 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -549,6 +549,9 @@ async fn list_tables_once( dispatch_mysql!(p, mode, db::mysql::list_tables, db::ob_oracle::list_tables, schema) .map(|tables| filter_table_infos(tables, filter, limit, offset)) } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { + db::questdb::list_tables(p, schema).await.map(|tables| filter_table_infos(tables, filter, limit, offset)) + } PoolKind::Postgres(p) => db::postgres::list_tables_filtered(p, schema, filter, limit, offset).await, PoolKind::Sqlite(p) => { db::sqlite::list_tables(p, schema).await.map(|tables| filter_table_infos(tables, filter, limit, offset)) @@ -944,6 +947,9 @@ async fn list_objects_once( db::mysql::list_objects(p, database).await } } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { + db::questdb::list_objects(p, schema).await + } PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await, _ => { drop(connections); @@ -1049,6 +1055,9 @@ async fn list_completion_objects_once( PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => { db::ob_oracle::list_objects(p, schema).await.map(filter_completion_objects) } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { + db::questdb::list_objects(p, schema).await.map(filter_completion_objects) + } PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(filter_completion_objects), PoolKind::SqlServer(_) => { drop(connections); @@ -1243,6 +1252,9 @@ pub async fn get_columns_core( dispatch_mysql!(p, mode, db::mysql::get_columns, db::ob_oracle::get_columns, database, table) .map(deduplicate_column_infos) } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { + db::questdb::get_columns(p, schema, table).await.map(deduplicate_column_infos) + } PoolKind::Postgres(p) => db::postgres::get_columns(p, schema, table).await.map(deduplicate_column_infos), PoolKind::Sqlite(p) => db::sqlite::get_columns(p, schema, table).await.map(deduplicate_column_infos), PoolKind::Rqlite(client) => { @@ -1321,6 +1333,9 @@ pub async fn list_indexes_core( } dispatch_mysql!(p, mode, db::mysql::list_indexes, db::ob_oracle::list_indexes, schema, table) } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { + db::questdb::list_indexes(p, schema, table).await + } PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await, PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_indexes(client, schema, table).await, @@ -1513,6 +1528,12 @@ pub async fn get_table_ddl_core( Err(_) => pg_ddl(p, schema, table).await, } } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => { + match db::questdb::questdb_table_or_view_ddl(p, table).await { + Ok(ddl) => Ok(ddl), + Err(_) => pg_ddl(p, schema, table).await, + } + } PoolKind::Postgres(p) => pg_ddl(p, schema, table).await, PoolKind::Sqlite(p) => sqlite_ddl(p, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await, @@ -1562,6 +1583,10 @@ fn is_mysql_system_database(name: &str) -> bool { matches!(name.to_ascii_lowercase().as_str(), "information_schema" | "mysql" | "performance_schema" | "sys") } +fn is_questdb_config(config: &ConnectionConfig) -> bool { + matches!(config.db_type, DatabaseType::Questdb) || matches!(config.driver_profile.as_deref(), Some("questdb")) +} + fn sql_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } @@ -1793,6 +1818,10 @@ pub async fn get_object_source_core( } else { match connections.get(&pool_key).ok_or("Pool not found")? { PoolKind::Mysql(pool, _) => mysql_object_source(pool, name, &object_type).await?, + PoolKind::Postgres(pool) if db_config.as_ref().is_some_and(is_questdb_config) => { + // only view + db::questdb::questdb_object_source(pool, name).await? + } PoolKind::Postgres(pool) => postgres_object_source(pool, schema, name, &object_type).await?, PoolKind::Sqlite(pool) => first_string_cell( db::sqlite::execute_query(pool, &sqlite_object_source_sql(name, &object_type)).await?, diff --git a/crates/dbx-core/src/schema/providers/native.rs b/crates/dbx-core/src/schema/providers/native.rs index b0e3e1634..13923bd03 100644 --- a/crates/dbx-core/src/schema/providers/native.rs +++ b/crates/dbx-core/src/schema/providers/native.rs @@ -45,6 +45,9 @@ pub(in crate::schema) async fn list_tables( let db = if schema.is_empty() { database } else { schema }; db::mysql::list_tables(p, db).await } + PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => { + db::questdb::list_tables(p, schema).await + } PoolKind::Postgres(p) => db::postgres::list_tables(p, schema).await, PoolKind::Sqlite(p) => db::sqlite::list_tables(p, schema).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_tables(client, schema).await, @@ -76,6 +79,9 @@ pub(in crate::schema) async fn list_objects( db::mysql::list_table_objects_show(p, database).await.map(Some) } PoolKind::Mysql(p, _) => db::mysql::list_objects(p, database).await.map(Some), + PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => { + db::questdb::list_objects(p, schema).await.map(Some) + } PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(Some), _ => Ok(None), } @@ -83,6 +89,7 @@ pub(in crate::schema) async fn list_objects( pub(in crate::schema) async fn list_completion_objects( pool: &PoolKind, + config: Option<&ConnectionConfig>, database: &str, schema: &str, ) -> Result>, String> { @@ -93,6 +100,9 @@ pub(in crate::schema) async fn list_completion_objects( PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => { db::ob_oracle::list_objects(p, schema).await.map(Some) } + PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => { + db::questdb::list_objects(p, schema).await.map(Some) + } PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await.map(Some), _ => Ok(None), } @@ -118,6 +128,9 @@ pub(in crate::schema) async fn get_columns( db::ob_oracle::get_columns(p, database, table).await } PoolKind::Mysql(p, _) => db::mysql::get_columns(p, database, table).await, + PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => { + db::questdb::get_columns(p, schema, table).await + } PoolKind::Postgres(p) => db::postgres::get_columns(p, schema, table).await, PoolKind::Sqlite(p) => db::sqlite::get_columns(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::get_columns(client, schema, table).await, @@ -129,6 +142,7 @@ pub(in crate::schema) async fn get_columns( pub(in crate::schema) async fn list_indexes( pool: &PoolKind, + config: Option<&ConnectionConfig>, database: &str, schema: &str, table: &str, @@ -138,6 +152,9 @@ pub(in crate::schema) async fn list_indexes( db::ob_oracle::list_indexes(p, schema, table).await } PoolKind::Mysql(p, _) => db::mysql::list_indexes(p, schema, table).await, + PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => { + db::questdb::list_indexes(p, schema, table).await + } PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await, PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::list_indexes(client, schema, table).await, @@ -200,6 +217,12 @@ pub(in crate::schema) async fn table_ddl( Err(_) => super::super::pg_ddl(p, schema, table).await, } } + PoolKind::Postgres(p) if config.is_some_and(is_questdb_config) => { + match db::questdb::questdb_table_or_view_ddl(p, table).await { + Ok(ddl) => Ok(ddl), + Err(_) => super::super::pg_ddl(p, schema, table).await, + } + } PoolKind::Postgres(p) => super::super::pg_ddl(p, schema, table).await, PoolKind::Sqlite(p) => super::super::sqlite_ddl(p, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await, @@ -306,3 +329,8 @@ fn filter_mysql_system_databases_for_config( fn is_mysql_system_database(name: &str) -> bool { matches!(name.to_ascii_lowercase().as_str(), "information_schema" | "mysql" | "performance_schema" | "sys") } + +fn is_questdb_config(config: &ConnectionConfig) -> bool { + matches!(config.db_type, DatabaseType::Questdb) + || matches!(config.driver_profile.as_deref(), Some("questdb")) +} \ No newline at end of file diff --git a/crates/dbx-core/src/sql_analysis.rs b/crates/dbx-core/src/sql_analysis.rs index 15e35b030..c545e52c3 100644 --- a/crates/dbx-core/src/sql_analysis.rs +++ b/crates/dbx-core/src/sql_analysis.rs @@ -90,7 +90,9 @@ fn starts_with_duckdb_parser_gap_sql(sql: &str) -> bool { fn normalize_dialect(dialect: Option<&str>) -> String { match dialect.unwrap_or("generic").to_ascii_lowercase().as_str() { - "postgres" | "postgresql" | "redshift" | "opengauss" | "gaussdb" | "highgo" => "postgres".to_string(), + "postgres" | "postgresql" | "redshift" | "opengauss" | "gaussdb" | "highgo" | "questdb" => { + "postgres".to_string() + } "mysql" | "mariadb" | "doris" | "starrocks" | "manticoresearch" | "oceanbase" => "mysql".to_string(), "sqlite" => "sqlite".to_string(), "sqlserver" | "mssql" => "sqlserver".to_string(), diff --git a/crates/dbx-core/src/sql_dialect/identifiers.rs b/crates/dbx-core/src/sql_dialect/identifiers.rs index f1ffb61fe..0e998638b 100644 --- a/crates/dbx-core/src/sql_dialect/identifiers.rs +++ b/crates/dbx-core/src/sql_dialect/identifiers.rs @@ -41,7 +41,8 @@ pub fn quote_table_identifier(database_type: Option, name: &str) - | DatabaseType::Databend | DatabaseType::Tdengine | DatabaseType::Access - | DatabaseType::Bigquery, + | DatabaseType::Bigquery + | DatabaseType::Questdb, ) => { format!("`{}`", name.replace('`', "``")) } @@ -69,7 +70,8 @@ pub(crate) fn quote_transfer_identifier(name: &str, database_type: &DatabaseType | DatabaseType::ClickHouse | DatabaseType::Doris | DatabaseType::StarRocks - | DatabaseType::Hive => format!("`{}`", name.replace('`', "``")), + | DatabaseType::Hive + | DatabaseType::Questdb => format!("`{}`", name.replace('`', "``")), DatabaseType::SqlServer => format!("[{}]", name.replace(']', "]]")), _ => format!("\"{}\"", name.replace('\"', "\"\"")), } @@ -77,7 +79,8 @@ pub(crate) fn quote_transfer_identifier(name: &str, database_type: &DatabaseType pub(crate) fn qualified_transfer_table(table_name: &str, schema: &str, database_type: &DatabaseType) -> String { let table = quote_transfer_identifier(table_name, database_type); - if schema.is_empty() || matches!(database_type, DatabaseType::Mysql | DatabaseType::MongoDb) { + if schema.is_empty() || matches!(database_type, DatabaseType::Mysql | DatabaseType::MongoDb | DatabaseType::Questdb) + { table } else { format!("{}.{}", quote_transfer_identifier(schema, database_type), table) diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index a187f7005..dd56250cd 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -110,6 +110,17 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String ); } + if database_type == Some(DatabaseType::Questdb) { + return build_questdb_table_select_sql( + &table_alias, + &where_clause, + &order, + &options.columns, + limit, + options.offset.unwrap_or(0), + ); + } + let offset = options.offset.filter(|offset| *offset > 0).map(|offset| format!(" OFFSET {offset}")).unwrap_or_default(); format!("SELECT {select_columns} FROM {table_alias}{where_clause}{order} LIMIT {limit}{offset};") @@ -332,3 +343,27 @@ pub(super) fn build_neo4j_table_select_sql(options: &TableDataSelectSqlOptions, let skip = options.offset.filter(|offset| *offset > 0).map(|offset| format!(" SKIP {offset}")).unwrap_or_default(); format!("MATCH (n:{label}){where_clause} RETURN {returns}{order}{skip} LIMIT {limit};") } + +pub(super) fn build_questdb_table_select_sql( + table: &str, + where_clause: &str, + order_by: &str, + columns: &[String], + limit: usize, + offset: usize, +) -> String { + let columns_sql = if columns.is_empty() { + "*".to_string() + } else { + columns + .iter() + .map(|column| quote_table_identifier(Some(DatabaseType::Questdb), column)) + .collect::>() + .join(", ") + }; + if offset == 0 { + return format!("SELECT {columns_sql} FROM {table}{where_clause}{order_by} LIMIT {limit}"); + } + let upper_bound = offset + limit; + return format!("SELECT {columns_sql} FROM {table}{where_clause}{order_by} LIMIT {offset}, {upper_bound}"); +} diff --git a/crates/dbx-core/src/table_structure_sql/column_alter.rs b/crates/dbx-core/src/table_structure_sql/column_alter.rs index dafaa3c82..cf4019953 100644 --- a/crates/dbx-core/src/table_structure_sql/column_alter.rs +++ b/crates/dbx-core/src/table_structure_sql/column_alter.rs @@ -551,6 +551,29 @@ pub(super) fn build_sqlite_existing_column_sql( statements } +pub(super) fn build_questdb_existing_column_sql(table: &str, column: &EditableStructureColumn) -> Vec { + let Some(original) = &column.original else { + return Vec::new(); + }; + let mut statements = Vec::new(); + let current_name = &column.name; + if column.name != original.name { + statements.push(format!( + "ALTER TABLE {table} RENAME COLUMN {} TO {};", + quote_ident(StructureDialect::Questdb, &original.name), + quote_ident(StructureDialect::Questdb, &column.name) + )); + } + if column.data_type.trim() != original.data_type.trim() { + statements.push(format!( + "ALTER TABLE {table} ALTER COLUMN {} TYPE {};", + quote_ident(StructureDialect::Questdb, current_name), + column_data_type(StructureDialect::Questdb, column) + )); + } + statements +} + pub(super) fn has_existing_column_attribute_change(column: &EditableStructureColumn) -> bool { let Some(original) = &column.original else { return false; diff --git a/crates/dbx-core/src/table_structure_sql/column_format.rs b/crates/dbx-core/src/table_structure_sql/column_format.rs index fc4a7ba83..6074f531f 100644 --- a/crates/dbx-core/src/table_structure_sql/column_format.rs +++ b/crates/dbx-core/src/table_structure_sql/column_format.rs @@ -5,6 +5,11 @@ use super::util::{clean, format_default_for_sql, normalize_default, quote_ident, pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStructureColumn) -> String { let data_type = column_data_type(dialect, column); let mut parts = vec![quote_ident(dialect, &column.name), data_type]; + // QuestDB SQL Syntax: ```ALTER TABLE tableName ADD COLUMN [IF NOT EXISTS] columnName typeDef``` + if dialect == StructureDialect::Questdb { + parts.insert(0, "IF NOT EXISTS".to_string()); + return parts.join(" "); + } if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse { parts.push("NOT NULL".to_string()); } @@ -74,6 +79,9 @@ pub(super) fn column_data_type(dialect: StructureDialect, column: &EditableStruc if dialect == StructureDialect::ManticoreSearch { return manticore_column_type(column); } + if dialect == StructureDialect::Questdb { + return questdb_column_type(column); + } normalize_column_data_type(dialect, &column.data_type) } @@ -186,3 +194,22 @@ pub(super) fn unwrap_clickhouse_nullable_type(data_type: &str) -> String { trimmed.to_string() } } + +/// QuestDB 类型处理 +/// geohash, decimal 这2种类型才能带() +pub(super) fn questdb_column_type(column: &EditableStructureColumn) -> String { + let data_type = column.data_type.trim().to_ascii_lowercase(); + let length_types: [&str; 2] = ["geohash", "decimal"]; + match data_type.find('(') { + Some(pos) => { + let base_type = &data_type[..pos]; + let params = &data_type[pos..]; + if length_types.contains(&base_type) { + format!("{}{}", base_type, params) + } else { + base_type.to_string() + } + } + None => data_type, + } +} diff --git a/crates/dbx-core/src/table_structure_sql/columns.rs b/crates/dbx-core/src/table_structure_sql/columns.rs index 0b022dd59..6113c7301 100644 --- a/crates/dbx-core/src/table_structure_sql/columns.rs +++ b/crates/dbx-core/src/table_structure_sql/columns.rs @@ -1,8 +1,8 @@ use super::column_alter::{ build_clickhouse_existing_column_sql, build_h2_existing_column_sql, build_informix_existing_column_sql, build_mysql_existing_column_sql, build_oracle_like_existing_column_sql, build_postgres_existing_column_sql, - build_sqlite_existing_column_sql, build_sqlserver_existing_column_sql, has_column_extra_change, - has_existing_column_attribute_change, + build_questdb_existing_column_sql, build_sqlite_existing_column_sql, build_sqlserver_existing_column_sql, + has_column_extra_change, has_existing_column_attribute_change, }; use super::column_format::column_definition; use super::comments::build_sqlserver_column_comment_sql; @@ -120,6 +120,7 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu &options.table_name, )), StructureDialect::Sqlite => statements.extend(build_sqlite_existing_column_sql(&table, column, warnings)), + StructureDialect::Questdb => statements.extend(build_questdb_existing_column_sql(&table, column)), _ => warnings.push(format!("Editing existing columns is not supported for {database_label} yet.")), } } diff --git a/crates/dbx-core/src/table_structure_sql/dialect.rs b/crates/dbx-core/src/table_structure_sql/dialect.rs index 439eac3f9..0ff5f66cf 100644 --- a/crates/dbx-core/src/table_structure_sql/dialect.rs +++ b/crates/dbx-core/src/table_structure_sql/dialect.rs @@ -13,6 +13,7 @@ pub(super) enum StructureDialect { ClickHouse, ManticoreSearch, Informix, + Questdb, Unsupported, } @@ -116,6 +117,23 @@ pub(super) fn capabilities_for(database_type: Option) -> TableStru alter_primary_key: true, ..base }, + Some(DatabaseType::Questdb) => TableStructureCapabilities { + dialect: StructureDialect::Questdb, + add_column: true, + drop_column: true, + rename_column: true, + alter_existing_column: true, + reorder_column: false, + comment: false, + create_index: false, + drop_index: false, + rebuild_index: false, + index_type: false, + index_include: false, + index_filter: false, + index_comment: false, + alter_primary_key: false, + }, Some(DatabaseType::Redshift | DatabaseType::Vertica) => TableStructureCapabilities { dialect: StructureDialect::Postgres, add_column: true, @@ -254,6 +272,7 @@ pub(super) fn dialect_label(dialect: StructureDialect) -> String { StructureDialect::ClickHouse => "clickhouse", StructureDialect::ManticoreSearch => "manticoresearch", StructureDialect::Informix => "informix", + StructureDialect::Questdb => "questdb", StructureDialect::Unsupported => "this database", } .to_string() @@ -272,6 +291,7 @@ pub(super) fn database_type_for_dialect(dialect: StructureDialect) -> Option Some(DatabaseType::ClickHouse), StructureDialect::ManticoreSearch => Some(DatabaseType::ManticoreSearch), StructureDialect::Informix => Some(DatabaseType::Informix), + StructureDialect::Questdb => Some(DatabaseType::Questdb), StructureDialect::Unsupported => None, } } diff --git a/crates/dbx-core/src/table_structure_sql/util.rs b/crates/dbx-core/src/table_structure_sql/util.rs index 41262c371..9e793c294 100644 --- a/crates/dbx-core/src/table_structure_sql/util.rs +++ b/crates/dbx-core/src/table_structure_sql/util.rs @@ -18,7 +18,9 @@ pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, t pub(super) fn quote_ident(dialect: StructureDialect, name: &str) -> String { match dialect { - StructureDialect::Mysql | StructureDialect::ManticoreSearch => format!("`{}`", name.replace('`', "``")), + StructureDialect::Mysql | StructureDialect::ManticoreSearch | StructureDialect::Questdb => { + format!("`{}`", name.replace('`', "``")) + } StructureDialect::SqlServer => format!("[{}]", name.replace(']', "]]")), StructureDialect::Informix if is_simple_informix_identifier(name) => name.to_string(), _ => format!("\"{}\"", name.replace('"', "\"\"")), diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index b47afde94..80da13b1f 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -1471,6 +1471,10 @@ pub fn pagination_sql( "SELECT {col_list} FROM {full_table} ORDER BY (SELECT NULL) OFFSET {offset} ROWS FETCH NEXT {limit} ROWS ONLY" ) } + DatabaseType::Questdb => { + let upper_bound = offset + limit as u64; + format!("SELECT {col_list} FROM {full_table} LIMIT {offset}, {upper_bound}") + } _ => { format!("SELECT {col_list} FROM {full_table} LIMIT {limit} OFFSET {offset}") } @@ -1497,6 +1501,11 @@ pub fn pagination_sql_with_order( "SELECT {col_list} FROM {full_table} ORDER BY {order_by} OFFSET {offset} ROWS FETCH NEXT {limit} ROWS ONLY" ) } + DatabaseType::Questdb => { + let upper_bound = offset + limit as u64; + let order_by = order_expression.map(|value| format!(" ORDER BY {value}")).unwrap_or_default(); + format!("SELECT {col_list} FROM {full_table}{order_by} LIMIT {offset}, {upper_bound}") + } _ => { let order_by = order_expression.map(|value| format!(" ORDER BY {value}")).unwrap_or_default(); format!("SELECT {col_list} FROM {full_table}{order_by} LIMIT {limit} OFFSET {offset}") @@ -1533,6 +1542,11 @@ pub fn pagination_sql_with_filter_order( "SELECT {col_list} FROM {full_table}{where_clause} ORDER BY {order_by} OFFSET {offset} ROWS FETCH NEXT {limit} ROWS ONLY" ) } + DatabaseType::Questdb => { + let upper_bound = offset + limit as u64; + let order_by = order_expression.map(|value| format!(" ORDER BY {value}")).unwrap_or_default(); + format!("SELECT {col_list} FROM {full_table}{where_clause}{order_by} LIMIT {offset}, {upper_bound}") + } _ => { let order_by = order_expression.map(|value| format!(" ORDER BY {value}")).unwrap_or_default(); format!("SELECT {col_list} FROM {full_table}{where_clause}{order_by} LIMIT {limit} OFFSET {offset}") @@ -3601,6 +3615,21 @@ mod tests { assert_eq!(sql, "SELECT \"id\", \"name\" FROM \"public\".\"users\" ORDER BY \"id\" LIMIT 100 OFFSET 200"); } + #[test] + fn questdb_pagination_uses_stable_primary_key_order() { + let sql = pagination_sql_with_order( + &[String::from("id"), String::from("name")], + "users", + "public", + &DatabaseType::Questdb, + 200, + 100, + &[String::from("id")], + ); + + assert_eq!(sql, "SELECT `id`, `name` FROM `users` ORDER BY `id` LIMIT 200, 300"); + } + #[test] fn filtered_pagination_preserves_where_and_order() { let sql = pagination_sql_with_filter_order( diff --git a/crates/dbx-core/tests/database_capabilities.rs b/crates/dbx-core/tests/database_capabilities.rs index 559238a29..567c909b4 100644 --- a/crates/dbx-core/tests/database_capabilities.rs +++ b/crates/dbx-core/tests/database_capabilities.rs @@ -141,6 +141,7 @@ fn classifies_agent_database_types() { assert!(!is_agent_type(&DatabaseType::Gaussdb)); assert!(!is_agent_type(&DatabaseType::Kwdb)); assert!(!is_agent_type(&DatabaseType::OpenGauss)); + assert!(!is_agent_type(&DatabaseType::Questdb)); } #[test] @@ -192,6 +193,7 @@ fn skips_tcp_probe_for_local_file_plugin_and_agent_types() { assert!(!skips_tcp_probe(&DatabaseType::Gaussdb)); assert!(!skips_tcp_probe(&DatabaseType::Kwdb)); assert!(!skips_tcp_probe(&DatabaseType::OpenGauss)); + assert!(!skips_tcp_probe(&DatabaseType::Questdb)); } #[test] diff --git a/docs/app/[lang]/databases/page.tsx b/docs/app/[lang]/databases/page.tsx index fc3d4900a..ab83eb47b 100644 --- a/docs/app/[lang]/databases/page.tsx +++ b/docs/app/[lang]/databases/page.tsx @@ -50,6 +50,7 @@ const databaseSupport = [ { name: "Cassandra", icon: "/icons/database/cassandra.svg", tone: "#1287b1" }, { name: "Kylin", icon: "/icons/database/apache_kylin.svg", tone: "#fb8c00" }, { name: "InfluxDB", icon: "/icons/database/influxdb.svg", tone: "#22adf6" }, + { name: "QuestDB", icon: "/icons/database/questdb.svg", tone: "#dc2626" }, { name: "IoTDB", icon: "/icons/database/iotdb.svg", tone: "#3cb371" }, { name: "KWDB", icon: "/icons/database/kwdb.svg", tone: "#6366f1" }, { name: "Vastbase", icon: "/icons/database/vastbase.png", tone: "#2563eb" }, diff --git a/docs/app/[lang]/page.tsx b/docs/app/[lang]/page.tsx index cf013304a..0d461ee18 100644 --- a/docs/app/[lang]/page.tsx +++ b/docs/app/[lang]/page.tsx @@ -102,6 +102,7 @@ const databaseSupport = [ { name: "Cassandra", icon: "/icons/database/cassandra.svg", tone: "#1287b1" }, { name: "Kylin", icon: "/icons/database/apache_kylin.svg", tone: "#fb8c00" }, { name: "InfluxDB", icon: "/icons/database/influxdb.svg", tone: "#22adf6" }, + { name: "QuestDB", icon: "/icons/database/questdb.svg", tone: "#dc2626" }, { name: "IoTDB", icon: "/icons/database/iotdb.svg", tone: "#3cb371" }, { name: "KWDB", icon: "/icons/database/kwdb.svg", tone: "#6366f1" }, { name: "Vastbase", icon: "/icons/database/vastbase.png", tone: "#2563eb" }, diff --git a/docs/content/docs/databases.cn.mdx b/docs/content/docs/databases.cn.mdx index 8ec1855ee..44c369ec9 100644 --- a/docs/content/docs/databases.cn.mdx +++ b/docs/content/docs/databases.cn.mdx @@ -16,7 +16,7 @@ description: 了解 DBX 可以连接哪些数据库,以及每类高级功能 | MySQL 兼容类型 | MariaDB、TiDB、OceanBase、Doris、SelectDB、StarRocks、Manticore Search、GoldenDB、TDSQL、PolarDB、GreatSQL、自定义 MySQL | 复用 MySQL 风格连接能力 | | PostgreSQL 兼容类型 | openGauss、KingBase、HighGo、Vastbase、CockroachDB、自定义 PostgreSQL | 复用 PostgreSQL 风格连接能力 | | 文件型数据库 | SQLite、DuckDB、Microsoft Access、RQLite、Turso | 选择本地数据库文件或 HTTP 端点,不填写主机和端口 | -| 时序与边缘数据库 | InfluxDB、IoTDB、TDengine、Databend | 面向时序、IoT 或边缘场景优化 | +| 时序与边缘数据库 | InfluxDB、IoTDB、TDengine、Databend、QuestDB | 面向时序、IoT 或边缘场景优化 | | Agent/JDBC 扩展类型 | H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、XuguDB、Databricks、IRIS、JDBC | 功能覆盖取决于对应驱动路径 | ## 默认端口 @@ -67,6 +67,7 @@ description: 了解 DBX 可以连接哪些数据库,以及每类高级功能 | SunDB | 22000 | | TDengine | 6041 | | XuguDB | 5138 | +| QuestDB | 8812 | SQLite、DuckDB、Access 和 JDBC 在不需要网络端口时,会在连接模型中保存为端口 `0`。 diff --git a/docs/content/docs/databases.mdx b/docs/content/docs/databases.mdx index f68cbf0cf..3bca9e60a 100644 --- a/docs/content/docs/databases.mdx +++ b/docs/content/docs/databases.mdx @@ -16,7 +16,7 @@ The connection dialog exposes ready-to-use profiles with default ports and drive | MySQL-compatible profiles | MariaDB, TiDB, OceanBase, Doris, SelectDB, StarRocks, Manticore Search, GoldenDB, TDSQL, PolarDB, GreatSQL, custom MySQL | Reuse MySQL-style connection handling where the engine speaks a compatible protocol | | PostgreSQL-compatible profiles | openGauss, KingBase, HighGo, Vastbase, CockroachDB, custom PostgreSQL | Reuse PostgreSQL-style connection handling where the engine speaks a compatible protocol | | File-based engines | SQLite, DuckDB, Microsoft Access, RQLite, Turso | Choose a local database file or HTTP endpoint instead of host and port | -| Time-series and edge | InfluxDB, IoTDB, TDengine, Databend | Optimized for time-series, IoT, or edge workloads | +| Time-series and edge | InfluxDB, IoTDB, TDengine, Databend, QuestDB | Optimized for time-series, IoT, or edge workloads | | Agent/JDBC-oriented engines | H2, Snowflake, Trino, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, XuguDB, Databricks, IRIS, JDBC | Feature coverage depends on the driver path used by that engine | ## Default Ports @@ -67,6 +67,7 @@ The connection dialog exposes ready-to-use profiles with default ports and drive | SunDB | 22000 | | TDengine | 6041 | | XuguDB | 5138 | +| QuestDB | 8812 | SQLite, DuckDB, Access, and JDBC profiles use port `0` in the saved connection model when a network port is not part of the connection. @@ -93,7 +94,7 @@ DBX intentionally enables advanced workflows only where the app has enough metad Most network databases support host, port, username, password, default database, optional URL parameters, SSL, connection color, SSH tunnel, and proxy settings. File-based engines replace host and port with a file picker. -DBX can also parse common connection URLs for engines such as MySQL, PostgreSQL, Redis, MongoDB, ClickHouse, SQL Server, Oracle, Elasticsearch, DM, GaussDB, KWDB, openGauss, TDengine, XuguDB, Access, Teradata, Vertica, Firebird, Exasol, GBase, YashanDB, SAP HANA, InfluxDB, IoTDB, etcd, RQLite, and Databricks. +DBX can also parse common connection URLs for engines such as MySQL, PostgreSQL, Redis, MongoDB, ClickHouse, SQL Server, Oracle, Elasticsearch, DM, GaussDB, KWDB, openGauss, TDengine, XuguDB, Access, Teradata, Vertica, Firebird, Exasol, GBase, YashanDB, SAP HANA, InfluxDB, QuestDB, IoTDB, etcd, RQLite, and Databricks. ### ClickHouse TLS diff --git a/packages/app-tests/databaseCapabilities.test.ts b/packages/app-tests/databaseCapabilities.test.ts index d7ac7e7db..2a7861367 100644 --- a/packages/app-tests/databaseCapabilities.test.ts +++ b/packages/app-tests/databaseCapabilities.test.ts @@ -64,7 +64,7 @@ test("treats Access as a local single-database agent driver", () => { }); test("exposes the extended JDBC agent ecosystem through driver management", () => { - for (const dbType of ["databricks", "saphana", "teradata", "vertica", "firebird", "exasol", "opengauss", "oceanbase-oracle", "gbase"] as const) { + for (const dbType of ["databricks", "saphana", "teradata", "vertica", "firebird", "exasol", "opengauss", "oceanbase-oracle", "gbase", "questdb"] as const) { assert.equal(supportsDriverManagement(dbType), true, `${dbType} should be agent-managed`); assert.equal(supportsDatabaseSearch(dbType), true, `${dbType} should support database search`); } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index e51ed0d4b..bfff43280 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -46,7 +46,7 @@ function formatQueryToolResult(result: QueryResult, title?: string) { } export const DBX_CONNECTION_TYPE_DESCRIPTION = - "Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, jdbc, access"; + "Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, questdb, gbase, h2, snowflake, trino, hive, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, jdbc, access"; const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]); export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer { diff --git a/packages/node-core/src/diagnostics.ts b/packages/node-core/src/diagnostics.ts index 859cfa7e7..c4562e900 100644 --- a/packages/node-core/src/diagnostics.ts +++ b/packages/node-core/src/diagnostics.ts @@ -2,7 +2,7 @@ import { access, readFile } from "node:fs/promises"; import { bridgePortFilePath, dbPath, appDataDir } from "./paths.js"; import { inspectConnectionStore } from "./connections.js"; -export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "manticoresearch", "sqlite", "rqlite", "gaussdb", "kwdb", "opengauss"] as const; +export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "manticoresearch", "sqlite", "rqlite", "gaussdb", "kwdb", "opengauss", "questdb"] as const; export type DirectQueryType = (typeof DIRECT_QUERY_TYPES)[number]; diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 15d52fa38..1863952b8 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -287,6 +287,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::Kwdb + | DatabaseType::Questdb | DatabaseType::OpenGauss => match db::postgres::connect(&url, connect_timeout).await { Ok(pool) => { pool.close(); @@ -497,6 +498,7 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::Kwdb + | DatabaseType::Questdb | DatabaseType::OpenGauss => PoolKind::Postgres(db::postgres::connect(&url, connect_timeout).await?), DatabaseType::Sqlite => { let extensions = db::sqlite::sqlite_extension_specs_from_url_params(db_config.url_params.as_deref())