diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue
index d81a860471..c58d9d5978 100644
--- a/apps/desktop/src/components/sidebar/ConnectionTree.vue
+++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue
@@ -1061,7 +1061,7 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
await store.loadRedisDatabases(connId);
} else if (config.db_type === "mongodb") {
await store.loadMongoDatabases(connId);
- } else if (config.db_type === "elasticsearch") {
+ } else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
await store.loadElasticsearchIndices(connId);
} else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") {
await store.loadVectorCollections(connId);
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index a1e6ec8399..0475d1dc30 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -569,7 +569,7 @@ async function toggle() {
await connectionStore.loadZooKeeperRoot(node.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(node.connectionId);
- } else if (config?.db_type === "elasticsearch") {
+ } else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
// Expand: list indices (like other db types list databases).
await connectionStore.loadElasticsearchIndices(node.connectionId);
} else if (config?.db_type === "milvus") {
@@ -3175,7 +3175,7 @@ const canOpenSqlFileExecution = computed(() => {
const canExportAllDatabases = computed(() => {
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
- return !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(dbType || "");
+ return !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(dbType || "");
});
const canOpenDiagram = computed(() => {
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue
index c047b1669a..1e3cafa77a 100644
--- a/apps/desktop/src/components/sidebar/TreeItem.vue
+++ b/apps/desktop/src/components/sidebar/TreeItem.vue
@@ -382,6 +382,7 @@ function connectionTooltipScheme(config: Pick
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
+const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
diff --git a/apps/desktop/src/composables/useQuickOpen.ts b/apps/desktop/src/composables/useQuickOpen.ts
index 2b3698a553..6864736595 100644
--- a/apps/desktop/src/composables/useQuickOpen.ts
+++ b/apps/desktop/src/composables/useQuickOpen.ts
@@ -17,7 +17,7 @@ const QUICK_OPEN_MAX_RESULTS = 200;
const INITIAL_SQL_LIBRARY_LIMIT = 20;
const INITIAL_SQL_FILE_LIMIT = 20;
-const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set(["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "etcd", "zookeeper", "mq", "nacos"]);
+const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "etcd", "zookeeper", "mq", "nacos"]);
export interface QuickOpenItem {
id: string;
diff --git a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts
index 596b7d1b39..d5c88cdb7e 100644
--- a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts
+++ b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts
@@ -234,7 +234,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
const canConfigureVisibleDatabases = computed(() => {
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
const databaseType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
- return databaseType !== "elasticsearch" && databaseType !== "qdrant" && databaseType !== "milvus" && databaseType !== "weaviate" && databaseType !== "chromadb" && databaseType !== "etcd" && databaseType !== "mq" && databaseType !== "nacos";
+ return databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "qdrant" && databaseType !== "milvus" && databaseType !== "weaviate" && databaseType !== "chromadb" && databaseType !== "etcd" && databaseType !== "mq" && databaseType !== "nacos";
});
const canConfigureVisibleSchemas = computed(() => {
if (!activeNode.value.connectionId) return false;
diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts
index 7b7d12ce11..38777671af 100644
--- a/apps/desktop/src/composables/useSqlExecution.ts
+++ b/apps/desktop/src/composables/useSqlExecution.ts
@@ -44,7 +44,7 @@ function isDangerousElasticsearchRequest(method: "GET" | "POST" | "PUT" | "DELET
}
export function isDangerousSql(sql: string, databaseType?: DatabaseType): boolean {
- if (databaseType === "elasticsearch") {
+ if (databaseType === "elasticsearch" || databaseType === "easysearch") {
const requests = splitSqlStatementRanges(sql, databaseType)
.map((statement) => parseElasticsearchRestRequestTarget(statement.sql))
.filter((request): request is NonNullable => request !== null);
@@ -360,7 +360,7 @@ export function useSqlExecution(deps: {
export function supportsSqlTemplateParameters(connection: Pick | undefined, sql = ""): boolean {
if (!connection) return false;
- if (connection.db_type === "elasticsearch") return !isElasticsearchRestRequestText(sql);
+ if (connection.db_type === "elasticsearch" || connection.db_type === "easysearch") return !isElasticsearchRestRequestText(sql);
return connection.db_type !== "redis" && connection.db_type !== "mongodb";
}
@@ -373,5 +373,5 @@ export function requiresDatabaseSelection(tab: QueryTab, connection: ConnectionC
// MySQL-compatible servers decide per statement whether a default database is required.
// Keep interactive execution connection-scoped instead of rejecting valid qualified or constant queries.
if (supportsConnectionLevelSqlExecution(connection)) return false;
- return !["elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "zookeeper"].includes(connection.db_type);
+ return !["elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "zookeeper"].includes(connection.db_type);
}
diff --git a/apps/desktop/src/lib/__tests__/app/documentStoreProvider.easysearch.spec.ts b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.easysearch.spec.ts
new file mode 100644
index 0000000000..09c98081c7
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/app/documentStoreProvider.easysearch.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { documentStoreProviderFor } from "@/lib/app/documentStoreProvider";
+
+describe("Easysearch document store provider", () => {
+ it("uses the Elasticsearch-compatible document provider", () => {
+ const provider = documentStoreProviderFor("easysearch");
+
+ expect(provider.kind).toBe("elasticsearch");
+ expect(
+ provider.queryPreview({
+ collection: "orders",
+ filterJson: '{"status":"paid"}',
+ sortJson: '{"created_at":-1}',
+ skip: 0,
+ limit: 100,
+ }),
+ ).toContain("POST /orders/_search");
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrl.easysearch.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrl.easysearch.spec.ts
new file mode 100644
index 0000000000..1bbbd847d5
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/connection/connectionUrl.easysearch.spec.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { connectionProfileForScheme, parseConnectionUrl } from "@/lib/connection/connectionUrl";
+
+describe("Easysearch connection URLs", () => {
+ it("parses the dedicated Easysearch scheme", () => {
+ expect(parseConnectionUrl("easysearch://dbx_test:secret@easysearch.example.com:9200")).toMatchObject({
+ dbType: "easysearch",
+ driverProfile: "easysearch",
+ driverLabel: "Easysearch",
+ host: "easysearch.example.com",
+ port: 9200,
+ username: "dbx_test",
+ password: "secret",
+ ssl: false,
+ });
+ });
+
+ it("keeps Easysearch selected for HTTPS URLs", () => {
+ expect(parseConnectionUrl("https://easysearch.example.com:9443", "easysearch")).toMatchObject({
+ dbType: "easysearch",
+ driverProfile: "easysearch",
+ port: 9443,
+ ssl: true,
+ });
+ });
+
+ it("exposes the Easysearch scheme profile", () => {
+ expect(connectionProfileForScheme("easysearch")).toEqual({
+ type: "easysearch",
+ profile: "easysearch",
+ label: "Easysearch",
+ defaultPort: 9200,
+ });
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts
index 2c06f4f476..f550728794 100644
--- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts
+++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnNameCopy.spec.ts
@@ -40,6 +40,7 @@ describe("dataGridColumnNameCopy", () => {
expect(supportsColumnNameQuoting("mongodb")).toBe(false);
expect(supportsColumnNameQuoting("redis")).toBe(false);
expect(supportsColumnNameQuoting("elasticsearch")).toBe(false);
+ expect(supportsColumnNameQuoting("easysearch")).toBe(false);
expect(supportsColumnNameQuoting("qdrant")).toBe(false);
expect(supportsColumnNameQuoting("jdbc")).toBe(false);
expect(supportsColumnNameQuoting("iotdb")).toBe(false);
diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSaveUi.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSaveUi.spec.ts
index cafbe9cef7..ce715f2fed 100644
--- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSaveUi.spec.ts
+++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSaveUi.spec.ts
@@ -5,6 +5,7 @@ describe("dataGridPreviewLabelKey", () => {
it("offers a query preview for document databases that never emit SQL", () => {
expect(dataGridPreviewLabelKey("mongodb")).toBe("toolbar.previewQuery");
expect(dataGridPreviewLabelKey("elasticsearch")).toBe("toolbar.previewQuery");
+ expect(dataGridPreviewLabelKey("easysearch")).toBe("toolbar.previewQuery");
});
it("keeps the SQL wording for SQL databases", () => {
diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
index 15f99a2d16..58e0e296e4 100644
--- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
+++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
@@ -73,6 +73,7 @@ describe("supportsSqlInListPaste", () => {
expect(supportsSqlInListPaste("redis")).toBe(false);
expect(supportsSqlInListPaste("mongodb")).toBe(false);
expect(supportsSqlInListPaste("elasticsearch")).toBe(false);
+ expect(supportsSqlInListPaste("easysearch")).toBe(false);
expect(supportsSqlInListPaste("qdrant")).toBe(false);
expect(supportsSqlInListPaste("milvus")).toBe(false);
expect(supportsSqlInListPaste("weaviate")).toBe(false);
diff --git a/apps/desktop/src/lib/__tests__/elasticsearchJsonResponse.spec.ts b/apps/desktop/src/lib/__tests__/elasticsearchJsonResponse.spec.ts
index a2725506ea..802dbabe72 100644
--- a/apps/desktop/src/lib/__tests__/elasticsearchJsonResponse.spec.ts
+++ b/apps/desktop/src/lib/__tests__/elasticsearchJsonResponse.spec.ts
@@ -16,4 +16,11 @@ describe("Elasticsearch JSON response detection", () => {
body: '[{"index":"data_pack_and_box_index_v1","docs.count":"42"}]',
});
});
+
+ it("routes Easysearch REST responses through the same JSON renderer", () => {
+ expect(elasticsearchJsonResponseForResult("easysearch", "GET /_cat/indices", catJsonResult)).toEqual({
+ status: 200,
+ body: '[{"index":"data_pack_and_box_index_v1","docs.count":"42"}]',
+ });
+ });
});
diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts
index b5d61ac7ab..302cced929 100644
--- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts
+++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts
@@ -428,8 +428,10 @@ POST /orders/_search
HEAD /orders`;
- expect(rangeSqlTexts(splitSqlStatementRanges(sql, "elasticsearch"))).toEqual(["GET /_nodes/stats/jvm?pretty", 'POST /orders/_search\n{\n "query": { "match_all": {} }\n}', "HEAD /orders"]);
- expect(hasMultipleExecutionTargets(sql, "elasticsearch")).toBe(true);
+ for (const databaseType of ["elasticsearch", "easysearch"] as const) {
+ expect(rangeSqlTexts(splitSqlStatementRanges(sql, databaseType))).toEqual(["GET /_nodes/stats/jvm?pretty", 'POST /orders/_search\n{\n "query": { "match_all": {} }\n}', "HEAD /orders"]);
+ expect(hasMultipleExecutionTargets(sql, databaseType)).toBe(true);
+ }
});
it("targets the Elasticsearch request following a comment", () => {
@@ -1178,6 +1180,7 @@ describe("supportsExecutionTargetPicker", () => {
expect(supportsExecutionTargetPicker("redis")).toBe(true);
expect(supportsExecutionTargetPicker("mongodb")).toBe(false);
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(true);
+ expect(supportsExecutionTargetPicker("easysearch")).toBe(true);
expect(supportsExecutionTargetPicker("qdrant")).toBe(false);
expect(supportsExecutionTargetPicker("milvus")).toBe(false);
expect(supportsExecutionTargetPicker("weaviate")).toBe(false);
diff --git a/apps/desktop/src/lib/app/documentStoreProvider.ts b/apps/desktop/src/lib/app/documentStoreProvider.ts
index 049a90af10..61f8932e1e 100644
--- a/apps/desktop/src/lib/app/documentStoreProvider.ts
+++ b/apps/desktop/src/lib/app/documentStoreProvider.ts
@@ -1,6 +1,6 @@
import type { ComposerTranslation } from "vue-i18n";
import { normalizeJsonArgument } from "@dbx-app/mongo-shell";
-import type { DatabaseType } from "@/types/database";
+import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
@@ -114,7 +114,7 @@ const elasticsearchDocumentProvider: DocumentStoreProvider = {
};
export function documentStoreProviderFor(databaseType?: DatabaseType): DocumentStoreProvider {
- return databaseType === "elasticsearch" ? elasticsearchDocumentProvider : mongoDocumentProvider;
+ return isElasticsearchCompatibleDatabaseType(databaseType) ? elasticsearchDocumentProvider : mongoDocumentProvider;
}
export function defaultDocumentFilterRule(id: string, fieldName = ""): DocumentFilterRule {
diff --git a/apps/desktop/src/lib/connection/connectionPresentation.ts b/apps/desktop/src/lib/connection/connectionPresentation.ts
index 80d9109daf..8590d60d3a 100644
--- a/apps/desktop/src/lib/connection/connectionPresentation.ts
+++ b/apps/desktop/src/lib/connection/connectionPresentation.ts
@@ -135,6 +135,7 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
return "oracle://user:password@host:port/service_name";
case "elasticsearch":
+ case "easysearch":
case "qdrant":
case "milvus":
case "weaviate":
diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts
index 4adee4e933..3afc9e6b35 100644
--- a/apps/desktop/src/lib/connection/connectionUrl.ts
+++ b/apps/desktop/src/lib/connection/connectionUrl.ts
@@ -45,6 +45,7 @@ const SCHEME_PROFILES: Record = {
mssql: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 },
oracle: { type: "oracle", profile: "oracle", label: "Oracle", defaultPort: 1521 },
elasticsearch: { type: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", defaultPort: 9200 },
+ easysearch: { type: "easysearch", profile: "easysearch", label: "Easysearch", defaultPort: 9200 },
qdrant: { type: "qdrant", profile: "qdrant", label: "Qdrant", defaultPort: 6333 },
milvus: { type: "milvus", profile: "milvus", label: "Milvus", defaultPort: 19530 },
weaviate: { type: "weaviate", profile: "weaviate", label: "Weaviate", defaultPort: 8080 },
@@ -72,6 +73,7 @@ const SCHEME_PROFILES: Record = {
const HTTP_SELECTED_PROFILES: Record = {
clickhouse: SCHEME_PROFILES.clickhouse,
elasticsearch: SCHEME_PROFILES.elasticsearch,
+ easysearch: SCHEME_PROFILES.easysearch,
qdrant: SCHEME_PROFILES.qdrant,
milvus: SCHEME_PROFILES.milvus,
weaviate: SCHEME_PROFILES.weaviate,
diff --git a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
index af4fa852d5..f351859531 100644
--- a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
+++ b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
// Turso and Cloudflare D1 connections target one fixed SQLite-compatible `main` namespace;
// listing account-level databases requires separate platform credentials, not the database connection.
-const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set(["turso", "cloudflare-d1", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper"]);
+const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts
index 2730f67629..e211f04914 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts
@@ -25,7 +25,7 @@ export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCo
}
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
-const NON_SQL_DATABASE_TYPES = new Set(["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]);
+const NON_SQL_DATABASE_TYPES = new Set(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]);
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
const UNQUOTABLE_DATABASE_TYPES = new Set(["jdbc", "iotdb"]);
diff --git a/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts b/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts
index 7dba246515..1d6af31b5e 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts
@@ -1,6 +1,6 @@
import type { DatabaseType } from "@/types/database";
-const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch"]);
+const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch", "easysearch"]);
export function dataGridPreviewLabelKey(databaseType?: DatabaseType): "toolbar.previewQuery" | "toolbar.previewSql" {
return databaseType && NON_SQL_PREVIEW_DATABASE_TYPES.has(databaseType) ? "toolbar.previewQuery" : "toolbar.previewSql";
diff --git a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
index 85237daee2..655d53d515 100644
--- a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
+++ b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
@@ -28,6 +28,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
mongodb: { connection: "special" },
oracle: { deferred: "Oracle schemas are users; database creation is not a normal connected DDL action" },
elasticsearch: { deferred: "index creation is not modeled as database creation" },
+ easysearch: { deferred: "index creation is not modeled as database creation" },
hbase: { deferred: "namespace creation needs dedicated HBase namespace options" },
qdrant: { deferred: "collection creation is separate from database creation" },
milvus: { deferred: "collection/database lifecycle needs a dedicated vector workflow" },
diff --git a/apps/desktop/src/lib/database/databasePropertyEditing.ts b/apps/desktop/src/lib/database/databasePropertyEditing.ts
index 0c359204e7..37b0598b1d 100644
--- a/apps/desktop/src/lib/database/databasePropertyEditing.ts
+++ b/apps/desktop/src/lib/database/databasePropertyEditing.ts
@@ -29,6 +29,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
mongodb: { deferred: "database options are not modeled as SQL database properties" },
oracle: { deferred: "Oracle database properties are instance/user/tablespace administration" },
elasticsearch: { deferred: "index settings are not database properties" },
+ easysearch: { deferred: "index settings are not database properties" },
hbase: { deferred: "namespace and table properties need a dedicated HBase workflow" },
qdrant: { deferred: "collection settings are not database properties" },
milvus: { deferred: "collection/database settings need a dedicated vector workflow" },
diff --git a/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts b/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts
index cd2f137fdf..4be3d72371 100644
--- a/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts
+++ b/apps/desktop/src/lib/elasticsearch/elasticsearchJsonResponse.ts
@@ -1,4 +1,4 @@
-import type { DatabaseType, QueryResult } from "@/types/database";
+import { isElasticsearchCompatibleDatabaseType, type DatabaseType, type QueryResult } from "@/types/database";
import { stripLeadingElasticsearchComments } from "@/lib/sql/sqlStatementRanges";
export interface ElasticsearchJsonResponse {
@@ -13,7 +13,7 @@ const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
* DBX asks unformatted CAT requests for JSON so they use this response panel.
*/
export function elasticsearchJsonResponseForResult(databaseType: DatabaseType | undefined, sourceStatement: string | undefined, result: QueryResult | undefined): ElasticsearchJsonResponse | undefined {
- if (databaseType !== "elasticsearch" || !result || typeof sourceStatement !== "string") return undefined;
+ if (!isElasticsearchCompatibleDatabaseType(databaseType) || !result || typeof sourceStatement !== "string") return undefined;
if (!ELASTICSEARCH_REST_STATEMENT.test(stripLeadingElasticsearchComments(sourceStatement))) return undefined;
if (result.columns.length !== 2 || result.columns[0] !== "status" || result.columns[1] !== "response" || result.rows.length !== 1) return undefined;
diff --git a/apps/desktop/src/lib/imports/datagripImport.ts b/apps/desktop/src/lib/imports/datagripImport.ts
index 993d9cadf5..22cbb8af90 100644
--- a/apps/desktop/src/lib/imports/datagripImport.ts
+++ b/apps/desktop/src/lib/imports/datagripImport.ts
@@ -59,6 +59,7 @@ const driverRefMap: Record = {
cockroachdb: { dbType: "postgres", profile: "cockroachdb", label: "CockroachDB", port: 26257, user: "root" },
redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" },
elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" },
+ easysearch: { dbType: "easysearch", profile: "easysearch", label: "Easysearch", port: 9200, user: "" },
h2: { dbType: "h2", profile: "h2", label: "H2", port: 9092, user: "sa" },
snowflake: { dbType: "snowflake", profile: "snowflake", label: "Snowflake", port: 443, user: "" },
kingbase: { dbType: "kingbase", profile: "kingbase", label: "KingbaseES", port: 54321, user: "SYSTEM" },
@@ -82,6 +83,7 @@ const productMap: Record = {
bigquery: { dbType: "bigquery", profile: "bigquery", label: "BigQuery", port: 443, user: "" },
redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" },
elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" },
+ easysearch: { dbType: "easysearch", profile: "easysearch", label: "Easysearch", port: 9200, user: "" },
snowflake: { dbType: "snowflake", profile: "snowflake", label: "Snowflake", port: 443, user: "" },
kingbase: { dbType: "kingbase", profile: "kingbase", label: "KingbaseES", port: 54321, user: "SYSTEM" },
};
diff --git a/apps/desktop/src/lib/imports/dbeaverImport.ts b/apps/desktop/src/lib/imports/dbeaverImport.ts
index 87763e09e0..a5ca39ce7d 100644
--- a/apps/desktop/src/lib/imports/dbeaverImport.ts
+++ b/apps/desktop/src/lib/imports/dbeaverImport.ts
@@ -52,6 +52,7 @@ const profileMap: Record = {
mongo: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" },
elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" },
+ easysearch: { dbType: "easysearch", profile: "easysearch", label: "Easysearch", port: 9200, user: "" },
doris: { dbType: "doris", profile: "doris", label: "Doris", port: 9030, user: "root" },
starrocks: { dbType: "starrocks", profile: "starrocks", label: "StarRocks", port: 9030, user: "root" },
dameng: { dbType: "dameng", profile: "dm", label: "达梦 Dameng", port: 5236, user: "SYSDBA" },
diff --git a/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts b/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts
index c08e20a204..faf7ac69bb 100644
--- a/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts
+++ b/apps/desktop/src/lib/sidebar/sidebarNodeOrdering.ts
@@ -40,7 +40,7 @@ export function sortSidebarTreeChildrenForParent(parent: Pick,
const regularChildren = normalized.filter((child) => child.type !== "user-admin" && child.type !== "saved-sql-root");
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlNodes, ...children, ...userAdminNodes];
- if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
+ if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
return withConnectionUtilityOrder(sortByLabel(regularChildren));
}
diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts
index ddbf175a4c..4308f44a94 100644
--- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts
+++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts
@@ -198,7 +198,17 @@ export function areSqlSemanticDiagnosticsEqual(left: readonly SqlSemanticDiagnos
}
export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
- if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
+ if (
+ options.databaseType === "mongodb" ||
+ options.databaseType === "elasticsearch" ||
+ options.databaseType === "easysearch" ||
+ options.databaseType === "qdrant" ||
+ options.databaseType === "milvus" ||
+ options.databaseType === "weaviate" ||
+ options.databaseType === "chromadb" ||
+ options.databaseType === "redis"
+ )
+ return false;
const context = getSqlCompletionContext(sql, cursor, options);
if (context.exclusiveColumnSuggestions) return false;
if (context.qualifier) return false;
@@ -207,7 +217,17 @@ export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, opt
}
export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
- if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
+ if (
+ options.databaseType === "mongodb" ||
+ options.databaseType === "elasticsearch" ||
+ options.databaseType === "easysearch" ||
+ options.databaseType === "qdrant" ||
+ options.databaseType === "milvus" ||
+ options.databaseType === "weaviate" ||
+ options.databaseType === "chromadb" ||
+ options.databaseType === "redis"
+ )
+ return false;
const context = getSqlCompletionContext(sql, cursor, options);
return context.exclusiveColumnSuggestions || !!context.qualifier || ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor));
}
diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts
index af7cc34208..8462c07453 100644
--- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts
+++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts
@@ -1,7 +1,7 @@
import type { SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
-import type { DatabaseType } from "@/types/database";
+import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
/**
* A contiguous range of SQL text expressed as document offsets plus the
@@ -13,10 +13,18 @@ export interface SqlTextRange {
sql: string;
}
-const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j"]);
+const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
+
+export function elasticsearchRestRequestRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
+ if (!isElasticsearchCompatibleDatabaseType(databaseType)) return [];
+ const requests = splitSqlStatementRanges(sql, databaseType);
+ return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
+}
+
+const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
- return !!databaseType && (databaseType === "redis" || databaseType === "elasticsearch" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
+ return !!databaseType && (databaseType === "redis" || isElasticsearchCompatibleDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
}
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
@@ -233,6 +241,7 @@ const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (databaseType === "elasticsearch" && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
+ if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (isElasticsearchCompatibleDatabaseType(databaseType) && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
const previous = statements[index - 1];
if (previous && isCursorInSameLineDelimiterGap(sql, previous.to, pos)) {
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType, parameterOptions);
diff --git a/apps/desktop/src/lib/table/tableMetadataCapabilities.ts b/apps/desktop/src/lib/table/tableMetadataCapabilities.ts
index a7c39925db..9a67849b05 100644
--- a/apps/desktop/src/lib/table/tableMetadataCapabilities.ts
+++ b/apps/desktop/src/lib/table/tableMetadataCapabilities.ts
@@ -31,6 +31,12 @@ const capabilityByType: Partial }, id = "es-1") {
+function seedConnectionNode(store: { treeNodes: TreeNode[]; connectedIds: Set }, id = "es-1", label = "Elasticsearch") {
store.connectedIds.add(id);
store.treeNodes.push({
id,
- label: "Elasticsearch",
+ label,
type: "connection",
connectionId: id,
isExpanded: false,
@@ -136,4 +136,35 @@ describe("connectionStore Elasticsearch open/expand", () => {
.sort(),
).toEqual(["orders", "users"]);
});
+
+ it("loads Easysearch indices through the Elasticsearch-compatible tree", async () => {
+ const elasticsearchListIndices = vi.fn().mockResolvedValue(["orders", "users"]);
+ const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
+
+ vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
+ vi.doMock("@/lib/backend/api", () => ({
+ checkConnectionHealth,
+ elasticsearchListIndices,
+ deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
+ loadSchemaCache: vi.fn().mockResolvedValue(null),
+ saveSchemaCache: vi.fn().mockResolvedValue(undefined),
+ saveConnections: vi.fn().mockResolvedValue(undefined),
+ saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
+ }));
+
+ const { useConnectionStore } = await import("@/stores/connectionStore");
+ const store = useConnectionStore();
+ store.addEphemeralConnection(esConnection("easysearch", "easysearch-1"));
+ seedConnectionNode(store, "easysearch-1", "Easysearch");
+
+ await store.loadElasticsearchIndices("easysearch-1");
+
+ expect(elasticsearchListIndices).toHaveBeenCalledWith("easysearch-1");
+ expect(
+ store.treeNodes
+ .find((node) => node.id === "easysearch-1")
+ ?.children?.map((node) => node.label)
+ .sort(),
+ ).toEqual(["orders", "users"]);
+ });
});
diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts
index c7f723637f..b13be0e4e9 100644
--- a/apps/desktop/src/stores/connectionStore.ts
+++ b/apps/desktop/src/stores/connectionStore.ts
@@ -967,6 +967,7 @@ export const useConnectionStore = defineStore("connection", () => {
oracle: "Oracle",
"mongodb-legacy": MONGO_LEGACY_DRIVER_LABEL,
elasticsearch: "Elasticsearch",
+ easysearch: "Easysearch",
qdrant: "Qdrant",
milvus: "Milvus",
weaviate: "Weaviate",
@@ -2602,7 +2603,7 @@ export const useConnectionStore = defineStore("connection", () => {
await loadZooKeeperRoot(connectionId);
} else if (config.db_type === "mongodb") {
await loadMongoDatabases(connectionId);
- } else if (config.db_type === "elasticsearch") {
+ } else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
// Reload: list indices.
await loadElasticsearchIndices(connectionId);
} else if (config.db_type === "milvus") {
@@ -3121,7 +3122,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
if (!connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
- if (!config || ["redis", "etcd", "zookeeper", "mongodb", "elasticsearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
+ if (!config || ["redis", "etcd", "zookeeper", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
const node = findConnectionNode(connectionId);
if (!node || node.type !== "connection" || node.isLoading || hasConnectionMetadataChildren(node.children)) return;
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
@@ -5007,7 +5008,7 @@ export const useConnectionStore = defineStore("connection", () => {
await loadZooKeeperRoot(node.connectionId);
} else if (config?.db_type === "mongodb") {
await loadMongoDatabases(node.connectionId);
- } else if (config?.db_type === "elasticsearch") {
+ } else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
await loadElasticsearchIndices(node.connectionId);
} else if (config?.db_type === "milvus") {
await loadMilvusDatabases(node.connectionId);
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 8498f9a4e9..04d3cd3be4 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -46,7 +46,7 @@ import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@
import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource";
import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
import { MAX_RESULT_PAGE_SIZE, normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
-import { executableStatementRanges, splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
+import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
@@ -372,14 +372,6 @@ function annotateQueryResultSource(result: QueryResult, sourceStatement: string,
return result;
}
-const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
-
-function elasticsearchRestRequestRanges(sql: string, databaseType?: DatabaseType) {
- if (databaseType !== "elasticsearch") return [];
- const requests = splitSqlStatementRanges(sql, databaseType);
- return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
-}
-
function elasticsearchHttpErrorStatus(result: QueryResult): number | undefined {
const statusIndex = result.columns.findIndex((column) => column.toLowerCase() === "status");
if (statusIndex < 0) return undefined;
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index a3c7f0e6b5..b4bfd687b4 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -12,6 +12,7 @@ export type DatabaseType =
| "mongodb"
| "oracle"
| "elasticsearch"
+ | "easysearch"
| "hbase"
| "qdrant"
| "milvus"
@@ -67,6 +68,10 @@ export type DatabaseType =
| "mq"
| "nacos";
+export function isElasticsearchCompatibleDatabaseType(dbType?: DatabaseType): boolean {
+ return dbType === "elasticsearch" || dbType === "easysearch";
+}
+
export interface SqlSnippet {
id: string;
label: string;
diff --git a/crates/dbx-cli/src/main.rs b/crates/dbx-cli/src/main.rs
index f54663b5af..788c9bec49 100644
--- a/crates/dbx-cli/src/main.rs
+++ b/crates/dbx-cli/src/main.rs
@@ -25,6 +25,7 @@ const BRIDGE_REQUIRED_TYPES: &[&str] = &[
"sqlserver",
"oracle",
"elasticsearch",
+ "easysearch",
"qdrant",
"milvus",
"weaviate",
diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json
index 19ace4bdd7..b19e83e576 100644
--- a/crates/dbx-core/assets/database-drivers.manifest.json
+++ b/crates/dbx-core/assets/database-drivers.manifest.json
@@ -355,6 +355,35 @@
"driverManagement": false
}
},
+ {
+ "dbType": "easysearch",
+ "label": "Easysearch",
+ "runtimeMode": "native",
+ "mcpMode": "bridge",
+ "singleConnectionPool": false,
+ "metadataConnectionScoped": false,
+ "skipTcpProbe": false,
+ "defaultPort": 9200,
+ "supportLevel": "connect",
+ "capabilities": {
+ "queryExecution": true,
+ "metadataBrowse": false,
+ "objectBrowser": false,
+ "objectSource": false,
+ "schemaSearch": false,
+ "diagram": false,
+ "tableDataEdit": false,
+ "tableStructureEdit": false,
+ "tableImport": false,
+ "dataTransfer": false,
+ "sqlFileExecution": false,
+ "databaseCreate": false,
+ "fieldLineage": false,
+ "sqlExplain": false,
+ "userAdmin": false,
+ "driverManagement": false
+ }
+ },
{
"dbType": "hbase",
"label": "Apache HBase",
diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs
index 06d10a0869..87eda46c5f 100644
--- a/crates/dbx-core/src/connection.rs
+++ b/crates/dbx-core/src/connection.rs
@@ -98,6 +98,7 @@ pub enum PoolKind {
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc>),
Elasticsearch(db::elasticsearch_driver::EsClient),
+ Easysearch(db::easysearch_driver::EasysearchClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
InfluxDb(db::influxdb_driver::InfluxdbClient),
@@ -1475,6 +1476,19 @@ impl AppState {
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;
PoolKind::Elasticsearch(client)
}
+ DatabaseType::Easysearch => {
+ let mut client = db::easysearch_driver::EasysearchClient::from_config(
+ &url,
+ Some(&db_config.username),
+ Some(&db_config.password),
+ db_config.ssl,
+ db_config.url_params.as_deref(),
+ db_config.external_config.as_ref(),
+ connect_timeout,
+ );
+ db::easysearch_driver::test_connection(&mut client, connect_timeout).await?;
+ PoolKind::Easysearch(client)
+ }
DatabaseType::Hbase => {
let client = db::hbase_driver::HBaseClient::new(
&url,
@@ -2317,6 +2331,18 @@ impl AppState {
}
}
}
+ PoolKind::Easysearch(client) => {
+ let mut client = client.clone();
+ drop(connections);
+ let timeout = crate::db::connection_timeout();
+ match db::easysearch_driver::test_connection(&mut client, timeout).await {
+ Ok(()) => false,
+ Err(err) => {
+ log::warn!("Easysearch connection pool '{pool_key}' is stale: {err}");
+ true
+ }
+ }
+ }
PoolKind::HBase(client) => {
let client = client.clone();
drop(connections);
@@ -3005,6 +3031,16 @@ impl AppState {
}
}
}
+ PoolKind::Easysearch(client) => {
+ let mut client = client.clone();
+ match db::easysearch_driver::test_connection(&mut client, timeout).await {
+ Ok(()) => true,
+ Err(e) => {
+ log::warn!("Easysearch connection pool '{key}' is unhealthy: {e}");
+ false
+ }
+ }
+ }
PoolKind::HBase(client) => match db::hbase_driver::test_connection(client, timeout).await {
Ok(_) => true,
Err(e) => {
@@ -3260,6 +3296,7 @@ enum KeepaliveTarget {
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc>),
Elasticsearch(db::elasticsearch_driver::EsClient),
+ Easysearch(db::easysearch_driver::EasysearchClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
InfluxDb(db::influxdb_driver::InfluxdbClient),
@@ -3279,6 +3316,7 @@ fn keepalive_target_from_pool(pool: &PoolKind, config: &ConnectionConfig) -> Opt
PoolKind::ClickHouse(client) => Some(KeepaliveTarget::ClickHouse(client.clone())),
PoolKind::SqlServer(client) => Some(KeepaliveTarget::SqlServer(client.clone())),
PoolKind::Elasticsearch(client) => Some(KeepaliveTarget::Elasticsearch(client.clone())),
+ PoolKind::Easysearch(client) => Some(KeepaliveTarget::Easysearch(client.clone())),
PoolKind::HBase(client) => Some(KeepaliveTarget::HBase(client.clone())),
PoolKind::VectorDb(client) => Some(KeepaliveTarget::VectorDb(client.clone())),
PoolKind::InfluxDb(client) => Some(KeepaliveTarget::InfluxDb(client.clone())),
@@ -3310,6 +3348,7 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
db::sqlserver::test_connection(&mut client).await
}
KeepaliveTarget::Elasticsearch(client) => db::elasticsearch_driver::test_connection(client, timeout).await,
+ KeepaliveTarget::Easysearch(client) => db::easysearch_driver::test_connection(client, timeout).await,
KeepaliveTarget::HBase(client) => db::hbase_driver::test_connection(client, timeout).await.map(|_| ()),
KeepaliveTarget::VectorDb(client) => db::vector_driver::test_connection(client, timeout).await,
KeepaliveTarget::InfluxDb(client) => db::influxdb_driver::test_connection(client, timeout).await,
@@ -3499,6 +3538,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
PoolKind::ClickHouse(client) => PoolKind::ClickHouse(client.clone()),
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
PoolKind::Elasticsearch(client) => PoolKind::Elasticsearch(client.clone()),
+ PoolKind::Easysearch(client) => PoolKind::Easysearch(client.clone()),
PoolKind::HBase(client) => PoolKind::HBase(client.clone()),
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
PoolKind::InfluxDb(client) => PoolKind::InfluxDb(client.clone()),
@@ -3543,6 +3583,9 @@ pub async fn close_pool_kind(pool: PoolKind) {
PoolKind::Elasticsearch(client) => {
drop(client);
}
+ PoolKind::Easysearch(client) => {
+ drop(client);
+ }
PoolKind::HBase(client) => {
drop(client);
}
@@ -3644,6 +3687,7 @@ fn base_pool_key_for_with_catalog(
&& matches!(
db_type,
DatabaseType::Elasticsearch
+ | DatabaseType::Easysearch
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
@@ -5076,6 +5120,7 @@ mod tests {
DatabaseType::ClickHouse,
DatabaseType::SqlServer,
DatabaseType::Elasticsearch,
+ DatabaseType::Easysearch,
DatabaseType::Kwdb,
] {
let mut config = mysql_config(Some("app"));
diff --git a/crates/dbx-core/src/db/easysearch_driver.rs b/crates/dbx-core/src/db/easysearch_driver.rs
new file mode 100644
index 0000000000..59236ce979
--- /dev/null
+++ b/crates/dbx-core/src/db/easysearch_driver.rs
@@ -0,0 +1,143 @@
+use serde_json::Value;
+use std::time::{Duration, Instant};
+
+use super::document_result::DocumentQueryResult;
+use super::elasticsearch_driver::{self, EsClient};
+use crate::db::ColumnInfo;
+use crate::types::QueryResult;
+
+#[derive(Clone)]
+pub struct EasysearchClient {
+ inner: EsClient,
+}
+
+impl EasysearchClient {
+ pub fn from_config(
+ url: &str,
+ username: Option<&str>,
+ password: Option<&str>,
+ tls_enabled: bool,
+ url_params: Option<&str>,
+ external_config: Option<&Value>,
+ timeout: Duration,
+ ) -> Self {
+ Self {
+ inner: EsClient::from_config(url, username, password, tls_enabled, url_params, external_config, timeout),
+ }
+ }
+}
+
+fn easysearch_error(error: String) -> String {
+ error.replace("Elasticsearch", "Easysearch")
+}
+
+fn parse_sql_response(body: &Value, start: Instant) -> Option {
+ elasticsearch_driver::parse_tabular_sql_response(body, start, "schema", "datarows", Some("total"))
+ .or_else(|| elasticsearch_driver::parse_tabular_sql_response(body, start, "columns", "rows", Some("total")))
+}
+
+pub async fn test_connection(client: &mut EasysearchClient, timeout: Duration) -> Result<(), String> {
+ elasticsearch_driver::test_connection(&mut client.inner, timeout).await.map_err(easysearch_error)
+}
+
+pub async fn list_indices(client: &EasysearchClient) -> Result, String> {
+ elasticsearch_driver::list_indices(&client.inner).await.map_err(easysearch_error)
+}
+
+pub async fn get_columns(client: &EasysearchClient, index: &str) -> Result, String> {
+ elasticsearch_driver::get_columns(&client.inner, index).await.map_err(easysearch_error)
+}
+
+pub async fn find_documents(
+ client: &EasysearchClient,
+ index: &str,
+ skip: u64,
+ limit: i64,
+ filter: Option<&str>,
+ sort: Option<&str>,
+) -> Result {
+ elasticsearch_driver::find_documents(&client.inner, index, skip, limit, filter, sort)
+ .await
+ .map_err(easysearch_error)
+}
+
+pub async fn count_documents(client: &EasysearchClient, index: &str, filter: Option<&str>) -> Result {
+ elasticsearch_driver::count_documents(&client.inner, index, filter).await.map_err(easysearch_error)
+}
+
+pub async fn insert_document(
+ client: &EasysearchClient,
+ index: &str,
+ doc_json: &str,
+ routing: Option<&str>,
+) -> Result {
+ elasticsearch_driver::insert_document(&client.inner, index, doc_json, routing).await.map_err(easysearch_error)
+}
+
+pub async fn update_document(
+ client: &EasysearchClient,
+ index: &str,
+ id: &str,
+ doc_json: &str,
+ routing: Option<&str>,
+) -> Result {
+ elasticsearch_driver::update_document(&client.inner, index, id, doc_json, routing).await.map_err(easysearch_error)
+}
+
+pub async fn delete_document(
+ client: &EasysearchClient,
+ index: &str,
+ id: &str,
+ routing: Option<&str>,
+) -> Result {
+ elasticsearch_driver::delete_document(&client.inner, index, id, routing).await.map_err(easysearch_error)
+}
+
+pub async fn execute_rest_query(client: &EasysearchClient, input: &str) -> Result {
+ elasticsearch_driver::execute_rest_query_with_sql_parser(&client.inner, input, parse_sql_response)
+ .await
+ .map_err(easysearch_error)
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ #[test]
+ fn parses_easysearch_sql_response() {
+ let result = super::parse_sql_response(
+ &json!({
+ "schema": [
+ { "name": "title", "type": "keyword" },
+ { "name": "price", "type": "integer" }
+ ],
+ "datarows": [["gamma", 30], ["beta", 20]],
+ "total": 3,
+ "size": 2,
+ "status": 200
+ }),
+ std::time::Instant::now(),
+ )
+ .unwrap();
+
+ assert_eq!(result.columns, vec!["title", "price"]);
+ assert_eq!(result.rows, vec![vec![json!("gamma"), json!(30)], vec![json!("beta"), json!(20)]]);
+ assert_eq!(result.affected_rows, 3);
+ }
+
+ #[test]
+ fn accepts_elasticsearch_compatible_sql_response() {
+ let result = super::parse_sql_response(
+ &json!({
+ "columns": [{ "name": "title", "type": "keyword" }],
+ "rows": [["gamma"]]
+ }),
+ std::time::Instant::now(),
+ )
+ .unwrap();
+
+ assert_eq!(result.columns, vec!["title"]);
+ assert_eq!(result.rows, vec![vec![json!("gamma")]]);
+ assert_eq!(result.affected_rows, 1);
+ }
+}
diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs
index 349574f6ca..f8bc9e5b2a 100644
--- a/crates/dbx-core/src/db/elasticsearch_driver.rs
+++ b/crates/dbx-core/src/db/elasticsearch_driver.rs
@@ -8,6 +8,7 @@ use std::time::Duration;
use super::{http_client_builder, with_connection_timeout};
use crate::db::document_result::DocumentQueryResult;
+use crate::types::QueryResult;
const ELASTICSEARCH_PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
@@ -1122,12 +1123,22 @@ fn validate_elasticsearch_ndjson(body: &str) -> Result {
Ok(normalized)
}
-pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result {
+pub(crate) type SqlResponseParser = fn(&serde_json::Value, std::time::Instant) -> Option;
+
+pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result {
+ execute_rest_query_with_sql_parser(client, input, parse_sql_response).await
+}
+
+pub(crate) async fn execute_rest_query_with_sql_parser(
+ client: &EsClient,
+ input: &str,
+ sql_response_parser: SqlResponseParser,
+) -> Result {
let start = std::time::Instant::now();
let input = strip_leading_elasticsearch_comments(input);
if let Some(search_query) = parse_select_star_search_query(input) {
- return execute_search_query(client, search_query, start).await;
+ return execute_search_query(client, search_query, start, sql_response_parser).await;
}
if is_elasticsearch_sql_query(input) {
@@ -1143,13 +1154,13 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result {
- return execute_translated_select_star(client, translated, start).await;
+ return execute_translated_select_star(client, translated, start, sql_response_parser).await;
}
Ok(None) => {}
Err(message) => return Err(format!("Elasticsearch SQL error: {message}")),
}
- return execute_sql_query(client, input, start).await;
+ return execute_sql_query(client, input, start, sql_response_parser).await;
}
// CAT APIs default to text, so request JSON for an unformatted CAT call.
@@ -1173,7 +1184,7 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result Result {
+ sql_response_parser: SqlResponseParser,
+) -> Result {
let report_index_total = query.from_plan_pagination;
let path = elasticsearch_index_path(&query.index, "_search");
let resp =
@@ -1208,7 +1220,7 @@ async fn execute_search_query(
// parser — needed below when we report total instead of rows.len().
let index_total = body.pointer("/hits/total/value").and_then(|v| v.as_u64());
- let mut result = parse_elasticsearch_response(status, body, start)?;
+ let mut result = parse_elasticsearch_response_with_sql_parser(status, body, start, sql_response_parser)?;
if report_index_total {
if let Some(total) = index_total {
result.affected_rows = total;
@@ -1217,12 +1229,22 @@ async fn execute_search_query(
Ok(result)
}
+#[cfg(test)]
fn parse_elasticsearch_response(
+ status: u16,
+ body: serde_json::Value,
+ start: std::time::Instant,
+) -> Result {
+ parse_elasticsearch_response_with_sql_parser(status, body, start, parse_sql_response)
+}
+
+fn parse_elasticsearch_response_with_sql_parser(
status: u16,
mut body: serde_json::Value,
start: std::time::Instant,
-) -> Result {
- if let Some(result) = parse_sql_response(&body, start) {
+ sql_response_parser: SqlResponseParser,
+) -> Result {
+ if let Some(result) = sql_response_parser(&body, start) {
Ok(result)
} else if let Some(aggs) = body.get("aggregations").or_else(|| body.get("aggs")).and_then(|v| v.as_object()) {
let (columns, rows) = parse_aggregations(aggs);
@@ -1434,11 +1456,21 @@ fn raw_json_response_result(
}
}
+#[cfg(test)]
fn parse_elasticsearch_rest_response(
status: u16,
body_text: &str,
start: std::time::Instant,
-) -> Result {
+) -> Result {
+ parse_elasticsearch_rest_response_with_sql_parser(status, body_text, start, parse_sql_response)
+}
+
+fn parse_elasticsearch_rest_response_with_sql_parser(
+ status: u16,
+ body_text: &str,
+ start: std::time::Instant,
+ sql_response_parser: SqlResponseParser,
+) -> Result {
if body_text.trim().is_empty() {
return Ok(json_response_result(status, &serde_json::Value::Null, start));
}
@@ -1459,7 +1491,7 @@ fn parse_elasticsearch_rest_response(
// responses, and aggregations so the desktop data grid can display and
// copy rows like relational results. Other JSON (mapping, cluster
// info, …) stays as a lossless status/response panel with the raw body.
- let mut result = parse_elasticsearch_response(status, body, start)?;
+ let mut result = parse_elasticsearch_response_with_sql_parser(status, body, start, sql_response_parser)?;
if result.columns == ["status".to_string(), "response".to_string()] {
return Ok(raw_json_response_result(status, body_text, start));
}
@@ -1589,7 +1621,8 @@ async fn execute_translated_select_star(
client: &EsClient,
translated: crate::db::elasticsearch_sql::TranslatedSelectStar,
start: std::time::Instant,
-) -> Result {
+ sql_response_parser: SqlResponseParser,
+) -> Result {
let report_index_total = !translated.user_limited;
let path = elasticsearch_index_path(&translated.index, "_search");
let resp = client
@@ -1602,7 +1635,7 @@ async fn execute_translated_select_star(
let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::Value::Null);
let index_total = body.pointer("/hits/total/value").and_then(|v| v.as_u64());
- let mut result = parse_elasticsearch_response(status, body, start)?;
+ let mut result = parse_elasticsearch_response_with_sql_parser(status, body, start, sql_response_parser)?;
if report_index_total {
if let Some(total) = index_total {
result.affected_rows = total;
@@ -1615,7 +1648,8 @@ async fn execute_sql_query(
client: &EsClient,
query: &str,
start: std::time::Instant,
-) -> Result {
+ sql_response_parser: SqlResponseParser,
+) -> Result {
let query = adapt_elasticsearch_sql_query(query);
let body = serde_json::json!({ "query": query });
let resp =
@@ -1627,7 +1661,7 @@ async fn execute_sql_query(
return Err(format_sql_error(status, &response_body));
}
- parse_sql_response(&response_body, start).ok_or_else(|| {
+ sql_response_parser(&response_body, start).ok_or_else(|| {
let pretty = serde_json::to_string_pretty(&response_body).unwrap_or_else(|_| response_body.to_string());
format!("Unexpected Elasticsearch SQL response: {pretty}")
})
@@ -1878,9 +1912,19 @@ fn next_char_at(query: &str, index: usize) -> Option {
query.get(index..)?.chars().next()
}
-fn parse_sql_response(body: &serde_json::Value, start: std::time::Instant) -> Option {
- let columns = body.get("columns")?.as_array()?;
- let rows = body.get("rows")?.as_array()?;
+fn parse_sql_response(body: &serde_json::Value, start: std::time::Instant) -> Option {
+ parse_tabular_sql_response(body, start, "columns", "rows", None)
+}
+
+pub(crate) fn parse_tabular_sql_response(
+ body: &serde_json::Value,
+ start: std::time::Instant,
+ columns_key: &str,
+ rows_key: &str,
+ total_key: Option<&str>,
+) -> Option {
+ let columns = body.get(columns_key)?.as_array()?;
+ let rows = body.get(rows_key)?.as_array()?;
let column_names: Vec = columns
.iter()
.filter_map(|column| column.get("name").and_then(|name| name.as_str()).map(str::to_string))
@@ -1893,12 +1937,15 @@ fn parse_sql_response(body: &serde_json::Value, start: std::time::Instant) -> Op
let result_rows: Vec> =
rows.iter().filter_map(|row| row.as_array().map(|values| values.to_vec())).collect();
- Some(crate::types::QueryResult {
+ Some(QueryResult {
columns: column_names,
column_types: Vec::new(),
column_sortables: vec![],
rows: result_rows,
- affected_rows: rows.len() as u64,
+ affected_rows: total_key
+ .and_then(|key| body.get(key))
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(rows.len() as u64),
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: body.get("cursor").and_then(|cursor| cursor.as_str()).map(str::to_string),
diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs
index cd406449de..60e4e7b7b3 100644
--- a/crates/dbx-core/src/db/mod.rs
+++ b/crates/dbx-core/src/db/mod.rs
@@ -10,6 +10,7 @@ pub mod duckdb_sql;
pub mod duckdb_worker_process;
#[cfg(feature = "duckdb-sidecar")]
pub mod duckdb_worker_protocol;
+pub mod easysearch_driver;
pub mod elasticsearch_driver;
pub mod elasticsearch_sql;
pub mod file_validator;
diff --git a/crates/dbx-core/src/document_ops.rs b/crates/dbx-core/src/document_ops.rs
index 10ca3dcf42..0451ff974d 100644
--- a/crates/dbx-core/src/document_ops.rs
+++ b/crates/dbx-core/src/document_ops.rs
@@ -1,7 +1,7 @@
use crate::connection::{AppState, PoolKind};
use crate::db::agent_driver::mongo_document_id_params;
use crate::db::document_result::DocumentQueryResult;
-use crate::db::{elasticsearch_driver, mongo_driver, vector_driver};
+use crate::db::{easysearch_driver, elasticsearch_driver, mongo_driver, vector_driver};
pub use crate::db::vector_driver::CollectionInfo;
@@ -55,6 +55,7 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
Err(error) => Err(error),
},
PoolKind::Elasticsearch(_) => Ok(vec!["default".to_string()]),
+ PoolKind::Easysearch(_) => Ok(vec!["default".to_string()]),
PoolKind::VectorDb(client) => vector_driver::list_databases(client).await,
PoolKind::Agent(client) => {
let mut client = client.lock().await;
@@ -217,6 +218,10 @@ pub async fn list_collections_core(
let names = sort_names(elasticsearch_driver::list_indices(client).await?);
Ok(names.into_iter().map(|n| CollectionInfo { name: n.clone(), id: n, ..Default::default() }).collect())
}
+ PoolKind::Easysearch(client) => {
+ let names = sort_names(easysearch_driver::list_indices(client).await?);
+ Ok(names.into_iter().map(|n| CollectionInfo { name: n.clone(), id: n, ..Default::default() }).collect())
+ }
PoolKind::VectorDb(client) => vector_driver::list_collections_with_db(client, database).await,
PoolKind::Agent(client) => {
let mut client = client.lock().await;
@@ -385,6 +390,11 @@ pub async fn find_documents_core(
drop(connections);
elasticsearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
}
+ PoolKind::Easysearch(client) => {
+ let client = client.clone();
+ drop(connections);
+ easysearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
+ }
PoolKind::VectorDb(client) => {
let client = client.clone();
drop(connections);
@@ -430,6 +440,11 @@ pub async fn count_elasticsearch_documents_core(
drop(connections);
elasticsearch_driver::count_documents(&client, index, filter).await
}
+ PoolKind::Easysearch(client) => {
+ let client = client.clone();
+ drop(connections);
+ easysearch_driver::count_documents(&client, index, filter).await
+ }
_ => Err("Not an Elasticsearch connection".to_string()),
}
}
@@ -456,6 +471,11 @@ pub async fn insert_document_core(
drop(connections);
elasticsearch_driver::insert_document(&client, collection, doc_json, routing).await
}
+ PoolKind::Easysearch(client) => {
+ let client = client.clone();
+ drop(connections);
+ easysearch_driver::insert_document(&client, collection, doc_json, routing).await
+ }
PoolKind::Agent(client) => {
let mut client = client.lock().await;
let result: serde_json::Value = client
@@ -491,6 +511,11 @@ pub async fn update_document_core(
// as was used to index the document.
elasticsearch_driver::update_document(&client, collection, id, doc_json, routing).await
}
+ PoolKind::Easysearch(client) => {
+ let client = client.clone();
+ drop(connections);
+ easysearch_driver::update_document(&client, collection, id, doc_json, routing).await
+ }
PoolKind::Agent(client) => {
let mut client = client.lock().await;
let result: serde_json::Value = client
@@ -526,6 +551,11 @@ pub async fn delete_document_core(
// as was used to index the document.
elasticsearch_driver::delete_document(&client, collection, id, routing).await
}
+ PoolKind::Easysearch(client) => {
+ let client = client.clone();
+ drop(connections);
+ easysearch_driver::delete_document(&client, collection, id, routing).await
+ }
PoolKind::Agent(client) => {
let mut client = client.lock().await;
let result: serde_json::Value =
diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs
index cfb30f9e3f..fee953379c 100644
--- a/crates/dbx-core/src/models/connection.rs
+++ b/crates/dbx-core/src/models/connection.rs
@@ -459,6 +459,8 @@ pub enum DatabaseType {
Oracle,
#[serde(rename = "elasticsearch")]
Elasticsearch,
+ #[serde(rename = "easysearch")]
+ Easysearch,
Hbase,
#[serde(rename = "qdrant")]
Qdrant,
@@ -1012,6 +1014,7 @@ impl ConnectionConfig {
}
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
DatabaseType::Elasticsearch
+ | DatabaseType::Easysearch
| DatabaseType::Hbase
| DatabaseType::Qdrant
| DatabaseType::Milvus
@@ -1161,6 +1164,7 @@ impl ConnectionConfig {
format!("oracle://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Elasticsearch
+ | DatabaseType::Easysearch
| DatabaseType::Hbase
| DatabaseType::Qdrant
| DatabaseType::Milvus
@@ -2334,6 +2338,12 @@ mod tests {
assert_eq!(serde_json::from_str::("\"zookeeper\"").unwrap(), DatabaseType::ZooKeeper);
}
+ #[test]
+ fn easysearch_database_type_uses_stable_wire_name() {
+ assert_eq!(serde_json::to_string(&DatabaseType::Easysearch).unwrap(), "\"easysearch\"");
+ assert_eq!(serde_json::from_str::("\"easysearch\"").unwrap(), DatabaseType::Easysearch);
+ }
+
#[test]
fn cloudflare_d1_database_type_and_api_url_are_stable() {
assert_eq!(serde_json::to_string(&DatabaseType::CloudflareD1).unwrap(), "\"cloudflare-d1\"");
diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs
index bad778f21e..1709652333 100644
--- a/crates/dbx-core/src/query.rs
+++ b/crates/dbx-core/src/query.rs
@@ -648,6 +648,7 @@ fn should_discard_pool_after_query_timeout(db_type: Option) -> boo
| DatabaseType::Turso
| DatabaseType::CloudflareD1
| DatabaseType::Elasticsearch
+ | DatabaseType::Easysearch
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
@@ -1190,6 +1191,23 @@ pub async fn do_execute(
}
result
}
+ PoolKind::Easysearch(client) => {
+ let client = client.clone();
+ let sql = sql.to_string();
+ let max_rows = options.max_rows;
+ drop(connections);
+ let result = wait_for_query_opt(
+ cancel_token,
+ query_timeout,
+ db::easysearch_driver::execute_rest_query(&client, &sql),
+ )
+ .await
+ .map(|result| truncate_result_with_max_rows(result, max_rows));
+ if matches!(result.as_ref(), Err(err) if should_discard_pool_after_error(pool_db_type, err)) {
+ state.remove_pool_by_key(pool_key).await;
+ }
+ result
+ }
PoolKind::VectorDb(client) => {
let client = client.clone();
let sql = sql.to_string();
@@ -2312,6 +2330,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
| PoolKind::Redis(_)
| PoolKind::MongoDb(_)
| PoolKind::Elasticsearch(_)
+ | PoolKind::Easysearch(_)
| PoolKind::VectorDb(_)
| PoolKind::InfluxDb(_)
| PoolKind::ExternalDriver { .. } => false,
@@ -2507,6 +2526,7 @@ pub async fn execute_statements_in_transaction_on_pool(
| PoolKind::Redis(_)
| PoolKind::MongoDb(_)
| PoolKind::Elasticsearch(_)
+ | PoolKind::Easysearch(_)
| PoolKind::VectorDb(_)
| PoolKind::InfluxDb(_)
| PoolKind::ExternalDriver { .. } => TxPath::None,
diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs
index 16535b62f8..ce3c41ed92 100644
--- a/crates/dbx-core/src/query_execution_sql.rs
+++ b/crates/dbx-core/src/query_execution_sql.rs
@@ -126,6 +126,7 @@ pub fn supports_sql_query(database_type: DatabaseType) -> bool {
DatabaseType::Redis
| DatabaseType::MongoDb
| DatabaseType::Elasticsearch
+ | DatabaseType::Easysearch
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
@@ -205,9 +206,87 @@ pub fn is_write_sql(sql: &str) -> bool {
/// executable comments and file exports, plus PostgreSQL-family/SQL Server
/// `SELECT ... INTO` table creation.
pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool {
+ if let Some(risk) = classify_search_engine_query_risk(sql, database_type) {
+ return risk != SearchEngineQueryRisk::ReadOnly;
+ }
is_write_sql_with_database_type(sql, Some(database_type))
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum SearchEngineQueryRisk {
+ ReadOnly,
+ Write,
+ Dangerous,
+}
+
+pub(crate) fn classify_search_engine_query_risk(
+ source: &str,
+ database_type: DatabaseType,
+) -> Option {
+ if !matches!(database_type, DatabaseType::Elasticsearch | DatabaseType::Easysearch) {
+ return None;
+ }
+ let source = strip_leading_search_engine_comments(source);
+ let request_line = source.lines().next()?.trim();
+ let mut parts = request_line.split_whitespace();
+ let method = parts.next()?.to_ascii_uppercase();
+ let path = parts.next()?;
+ let path = path.split('?').next().unwrap_or(path).trim_end_matches('/');
+ let segments = path.split('/').filter(|segment| !segment.is_empty()).collect::>();
+ let has_segment = |candidate: &str| segments.iter().any(|segment| segment.eq_ignore_ascii_case(candidate));
+ let has_document_id = |candidate: &str| {
+ segments
+ .iter()
+ .position(|segment| segment.eq_ignore_ascii_case(candidate))
+ .is_some_and(|index| segments.get(index + 1).is_some_and(|value| !value.is_empty()))
+ };
+
+ match method.as_str() {
+ "GET" | "HEAD" | "OPTIONS" => Some(SearchEngineQueryRisk::ReadOnly),
+ "POST"
+ if [
+ "_search",
+ "_count",
+ "_sql",
+ "_msearch",
+ "_field_caps",
+ "_terms_enum",
+ "_validate",
+ "_explain",
+ "_rank_eval",
+ "_search_shards",
+ ]
+ .iter()
+ .any(|endpoint| has_segment(endpoint)) =>
+ {
+ Some(SearchEngineQueryRisk::ReadOnly)
+ }
+ "POST" if ["_doc", "_create", "_update", "_bulk"].iter().any(|endpoint| has_segment(endpoint)) => {
+ Some(SearchEngineQueryRisk::Write)
+ }
+ "PUT" if has_document_id("_doc") || has_document_id("_create") => Some(SearchEngineQueryRisk::Write),
+ "DELETE" if has_document_id("_doc") => Some(SearchEngineQueryRisk::Write),
+ "POST" | "PUT" | "PATCH" | "DELETE" => Some(SearchEngineQueryRisk::Dangerous),
+ _ => None,
+ }
+}
+
+fn strip_leading_search_engine_comments(input: &str) -> &str {
+ let mut rest = input;
+ loop {
+ rest = rest.trim_start();
+ if let Some(comment) = rest.strip_prefix('#').or_else(|| rest.strip_prefix("//")) {
+ rest = comment.split_once('\n').map_or("", |(_, remaining)| remaining);
+ continue;
+ }
+ if let Some(comment) = rest.strip_prefix("/*") {
+ rest = comment.split_once("*/").map_or("", |(_, remaining)| remaining);
+ continue;
+ }
+ return rest.trim();
+ }
+}
+
fn is_write_sql_with_database_type(sql: &str, database_type: Option) -> bool {
if database_type == Some(DatabaseType::SqlServer) && is_sqlserver_showplan_xml_set(sql) {
return false;
@@ -1258,4 +1337,17 @@ mod tests {
// comments even inside string literals
assert_eq!(strip_sql_comments("SELECT 'hello /* not a comment */'"), "SELECT 'hello '");
}
+
+ #[test]
+ fn classifies_search_engine_rest_writes() {
+ assert!(!is_write_sql_for_database("GET /_cluster/health", DatabaseType::Easysearch));
+ assert!(!is_write_sql_for_database(
+ "POST /products/_search\n{\"query\":{\"match_all\":{}}}",
+ DatabaseType::Easysearch
+ ));
+ assert!(is_write_sql_for_database(
+ "PUT /products/_doc/1?refresh=true\n{\"name\":\"Notebook\"}",
+ DatabaseType::Easysearch
+ ));
+ }
}
diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs
index a7416698f9..bcd6bc051f 100644
--- a/crates/dbx-core/src/query_result_sql.rs
+++ b/crates/dbx-core/src/query_result_sql.rs
@@ -184,7 +184,7 @@ pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlB
let safe_limit = options.limit.max(1);
let safe_offset = options.offset;
- if options.database_type == Some(DatabaseType::Elasticsearch) {
+ if matches!(options.database_type, Some(DatabaseType::Elasticsearch | DatabaseType::Easysearch)) {
// If the user wrote their own LIMIT, leave the SQL alone — they
// explicitly bounded the result set and the front-end will paginate
// client-side. Otherwise wrap with an explicit OFFSET (even when
@@ -226,7 +226,7 @@ pub fn build_count_query_sql(options: CountQuerySqlOptions) -> QuerySqlBuildResu
}
// ES SQL can't wrap a SELECT in `SELECT COUNT(*) FROM (...)` — the
// driver already reports the true match count via affected_rows.
- if options.database_type == Some(DatabaseType::Elasticsearch) {
+ if matches!(options.database_type, Some(DatabaseType::Elasticsearch | DatabaseType::Easysearch)) {
return err("unsupported");
}
if options.database_type == Some(DatabaseType::SqlServer) && !sql_server_derived_table_projection_safe(&statement) {
@@ -1402,6 +1402,23 @@ fn fallback_alias(index: usize) -> String {
mod tests {
use super::*;
+ #[test]
+ fn easysearch_uses_elasticsearch_sql_pagination_rules() {
+ let paginated = build_paginated_query_sql(PaginatedQuerySqlOptions {
+ original_sql: "SELECT name FROM products".to_string(),
+ database_type: Some(DatabaseType::Easysearch),
+ limit: 100,
+ offset: 200,
+ });
+ let counted = build_count_query_sql(CountQuerySqlOptions {
+ original_sql: "SELECT name FROM products".to_string(),
+ database_type: Some(DatabaseType::Easysearch),
+ });
+
+ assert_eq!(paginated.sql.as_deref(), Some("SELECT name FROM products LIMIT 100 OFFSET 200;"));
+ assert_eq!(counted, err("unsupported"));
+ }
+
#[test]
fn wraps_single_select_query_with_limit_and_offset() {
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs
index 80892f651c..c8e17f0932 100644
--- a/crates/dbx-core/src/schema.rs
+++ b/crates/dbx-core/src/schema.rs
@@ -1970,6 +1970,10 @@ async fn list_tables_once(
.await
.map(|names| collection_names_to_tables(names, "INDEX"))
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
+ PoolKind::Easysearch(client) => db::easysearch_driver::list_indices(client)
+ .await
+ .map(|names| collection_names_to_tables(names, "INDEX"))
+ .map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
PoolKind::HBase(client) => db::hbase_driver::list_tables(client, database)
.await
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
@@ -4544,6 +4548,9 @@ pub async fn get_columns_core_for_session(
PoolKind::Elasticsearch(client) => {
db::elasticsearch_driver::get_columns(client, table).await.map(deduplicate_column_infos)
}
+ PoolKind::Easysearch(client) => {
+ db::easysearch_driver::get_columns(client, table).await.map(deduplicate_column_infos)
+ }
PoolKind::HBase(client) => {
db::hbase_driver::get_columns(client, database, table).await.map(deduplicate_column_infos)
}
diff --git a/crates/dbx-core/src/schema/providers/native.rs b/crates/dbx-core/src/schema/providers/native.rs
index 2db3bc6b1a..e213d2dabc 100644
--- a/crates/dbx-core/src/schema/providers/native.rs
+++ b/crates/dbx-core/src/schema/providers/native.rs
@@ -61,6 +61,9 @@ pub(in crate::schema) async fn list_tables(
PoolKind::Elasticsearch(client) => {
db::elasticsearch_driver::list_indices(client).await.map(|names| collection_names_to_tables(names, "INDEX"))
}
+ PoolKind::Easysearch(client) => {
+ db::easysearch_driver::list_indices(client).await.map(|names| collection_names_to_tables(names, "INDEX"))
+ }
PoolKind::HBase(client) => db::hbase_driver::list_tables(client, database).await,
PoolKind::VectorDb(client) => db::vector_driver::list_collections(client)
.await
@@ -151,6 +154,7 @@ pub(in crate::schema) async fn get_columns(
PoolKind::Rqlite(client) => db::rqlite_driver::get_columns(client, schema, table).await,
PoolKind::Turso(client) => db::turso_driver::get_columns(client, schema, table).await,
PoolKind::Elasticsearch(client) => db::elasticsearch_driver::get_columns(client, table).await,
+ PoolKind::Easysearch(client) => db::easysearch_driver::get_columns(client, table).await,
PoolKind::HBase(client) => db::hbase_driver::get_columns(client, database, table).await,
PoolKind::VectorDb(_) => Ok(vec![]),
_ => Ok(vec![]),
diff --git a/crates/dbx-core/src/sql_dialect/ddl_profile.rs b/crates/dbx-core/src/sql_dialect/ddl_profile.rs
index 1bcfb64966..7d179cce1b 100644
--- a/crates/dbx-core/src/sql_dialect/ddl_profile.rs
+++ b/crates/dbx-core/src/sql_dialect/ddl_profile.rs
@@ -673,8 +673,8 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
| Bigquery | Kylin | Oscar | Tdengine | Iotdb | Databricks | Jdbc => conservative_ansi(db_type),
// Non-tabular / not applicable for relational CREATE TABLE
- Redis | MongoDb | Elasticsearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j | Cassandra | Etcd
- | ZooKeeper | Nacos | InfluxDb | MessageQueue | Hbase => conservative_ansi(db_type),
+ Redis | MongoDb | Elasticsearch | Easysearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j | Cassandra
+ | Etcd | ZooKeeper | Nacos | InfluxDb | MessageQueue | Hbase => conservative_ansi(db_type),
}
}
diff --git a/crates/dbx-core/src/sql_risk.rs b/crates/dbx-core/src/sql_risk.rs
index db6f4ddf6c..0315a4b5a4 100644
--- a/crates/dbx-core/src/sql_risk.rs
+++ b/crates/dbx-core/src/sql_risk.rs
@@ -676,6 +676,13 @@ pub fn classify_sql_risk(sql: &str, dialect: &str) -> Result {
/// Classify SQL risk using both the parser dialect and the concrete database
/// type so dialect-specific write forms cannot be mistaken for read queries.
pub fn classify_sql_risk_for_database(sql: &str, database_type: DatabaseType) -> Result {
+ if let Some(risk) = crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type) {
+ return Ok(match risk {
+ crate::query_execution_sql::SearchEngineQueryRisk::ReadOnly => SqlRisk::ReadOnly,
+ crate::query_execution_sql::SearchEngineQueryRisk::Write => SqlRisk::Write,
+ crate::query_execution_sql::SearchEngineQueryRisk::Dangerous => SqlRisk::Ddl,
+ });
+ }
let database_type_name = format!("{database_type:?}");
let normalized = normalize_dialect(&database_type_name);
classify_sql_risk_with_database(sql, normalized, Some(database_type))
@@ -686,6 +693,9 @@ pub fn classify_sql_risk_for_database(sql: &str, database_type: DatabaseType) ->
/// and single-table UPDATE/DELETE statements with an effective predicate;
/// broader or opaque mutations require central high-risk permission.
pub fn is_dangerous_sql_for_database(sql: &str, database_type: DatabaseType) -> bool {
+ if let Some(risk) = crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type) {
+ return risk == crate::query_execution_sql::SearchEngineQueryRisk::Dangerous;
+ }
let database_type_name = format!("{database_type:?}");
let normalized = normalize_dialect(&database_type_name);
let parser_dialect = resolve_dialect(normalized);
@@ -712,6 +722,9 @@ pub fn is_dangerous_sql_for_database(sql: &str, database_type: DatabaseType) ->
/// A `USE` statement mutates pooled/session state and could redirect later SQL,
/// so it is forbidden independently of read/write and high-risk permissions.
pub fn mcp_sql_has_forbidden_database_switch(sql: &str, database_type: DatabaseType) -> bool {
+ if crate::query_execution_sql::classify_search_engine_query_risk(sql, database_type).is_some() {
+ return false;
+ }
let database_type_name = format!("{database_type:?}");
let normalized = normalize_dialect(&database_type_name);
let dialect = resolve_dialect(normalized);
@@ -1234,4 +1247,25 @@ mod tests {
// This depends on sqlparser's coverage, but we can test the catch-all
assert_eq!(classify_sql_risk("GRANT SELECT ON users TO admin", "postgres").unwrap(), SqlRisk::Ddl);
}
+
+ #[test]
+ fn classifies_search_engine_rest_risk_by_method_and_path() {
+ for database_type in [DatabaseType::Elasticsearch, DatabaseType::Easysearch] {
+ assert_eq!(
+ classify_sql_risk_for_database("GET /_cluster/health", database_type).unwrap(),
+ SqlRisk::ReadOnly
+ );
+ assert_eq!(
+ classify_sql_risk_for_database("POST /products/_search\n{}", database_type).unwrap(),
+ SqlRisk::ReadOnly
+ );
+ assert_eq!(
+ classify_sql_risk_for_database("PUT /products/_doc/1\n{}", database_type).unwrap(),
+ SqlRisk::Write
+ );
+ assert!(!is_dangerous_sql_for_database("PUT /products/_doc/1\n{}", database_type));
+ assert_eq!(classify_sql_risk_for_database("DELETE /products", database_type).unwrap(), SqlRisk::Ddl);
+ assert!(is_dangerous_sql_for_database("DELETE /products", database_type));
+ }
+ }
}
diff --git a/docs/app/[lang]/databases/page.tsx b/docs/app/[lang]/databases/page.tsx
index 35a3b869ac..3780da948f 100644
--- a/docs/app/[lang]/databases/page.tsx
+++ b/docs/app/[lang]/databases/page.tsx
@@ -18,6 +18,7 @@ const databaseSupport = [
{ name: "MongoDB", icon: "/icons/database/mongodb.svg", tone: "#47a248" },
{ name: "Oracle", icon: "/icons/database/oracle.svg", tone: "#f80000" },
{ name: "Elasticsearch", icon: "/icons/database/elasticsearch.svg", tone: "#00bfb3" },
+ { name: "Easysearch", icon: "/icons/database/easysearch.svg", tone: "#231815" },
{ name: "Qdrant", icon: "/icons/database/qdrant.svg", tone: "#dc244c" },
{ name: "Milvus", icon: "/icons/database/milvus.png", tone: "#00a1ea" },
{ name: "Weaviate", icon: "/icons/database/weaviate.svg", tone: "#00b894" },
diff --git a/docs/app/[lang]/page.tsx b/docs/app/[lang]/page.tsx
index 631377cd3e..047bedb7eb 100644
--- a/docs/app/[lang]/page.tsx
+++ b/docs/app/[lang]/page.tsx
@@ -55,6 +55,7 @@ const databaseSupport = [
{ name: "MongoDB", icon: "/icons/database/mongodb.svg", tone: "#47a248" },
{ name: "Oracle", icon: "/icons/database/oracle.svg", tone: "#f80000" },
{ name: "Elasticsearch", icon: "/icons/database/elasticsearch.svg", tone: "#00bfb3" },
+ { name: "Easysearch", icon: "/icons/database/easysearch.svg", tone: "#231815" },
{ name: "Qdrant", icon: "/icons/database/qdrant.svg", tone: "#dc244c" },
{ name: "Milvus", icon: "/icons/database/milvus.png", tone: "#00a1ea" },
{ name: "Weaviate", icon: "/icons/database/weaviate.svg", tone: "#00b894" },
diff --git a/docs/content/docs/databases.cn.mdx b/docs/content/docs/databases.cn.mdx
index 03c8c525a8..29d2014b66 100644
--- a/docs/content/docs/databases.cn.mdx
+++ b/docs/content/docs/databases.cn.mdx
@@ -12,7 +12,7 @@ description: 了解 DBX 可以连接哪些数据库,以及每类高级功能
| 分组 | 类型 | 说明 |
| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| 核心 SQL 数据库 | MySQL、PostgreSQL、SQLite、DuckDB、ClickHouse、SQL Server、Oracle、Redshift、DM、GaussDB、KWDB、Teradata、Vertica、Exasol、Firebird、SAP HANA、崖山 YashanDB、GBase | 直接出现在 DBX 连接选择器中 |
-| 文档、键值、向量与配置服务 | Redis、MongoDB、Elasticsearch、Qdrant、Milvus、Weaviate、ChromaDB、etcd、Apache ZooKeeper、Nacos | 根据数据模型使用专门浏览器、管理控制台或查询流程,不完全等同于关系型表格 |
+| 文档、键值、向量与配置服务 | Redis、MongoDB、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、ChromaDB、etcd、Apache ZooKeeper、Nacos | 根据数据模型使用专门浏览器、管理控制台或查询流程,不完全等同于关系型表格 |
| 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 端点,不填写主机和端口 |
@@ -31,7 +31,7 @@ description: 了解 DBX 可以连接哪些数据库,以及每类高级功能
| ClickHouse | 8123 |
| SQL Server | 1433 |
| Oracle | 1521 |
-| Elasticsearch | 9200 |
+| Elasticsearch / Easysearch | 9200 |
| Qdrant | 6333 |
| Milvus | 19530 |
| Weaviate | 8080 |
@@ -108,7 +108,7 @@ DBX 只会在具备足够元数据和 SQL 生成能力的数据库上启用高
| 创建数据库 | MySQL、PostgreSQL、SQL Server、ClickHouse、Oracle、DM、GaussDB、KWDB、Doris、StarRocks、Redshift、Teradata、Vertica |
| 字段血缘 | MySQL、PostgreSQL、SQLite、Cloudflare D1、SQL Server、Oracle、Redshift、DM、GaussDB、KWDB |
| 数据传输 | MySQL、PostgreSQL、SQLite、Cloudflare D1、SQL Server、Oracle、ClickHouse、DuckDB、DM、GaussDB、KWDB |
-| 不支持 SQL 文件执行 | Redis、MongoDB、Elasticsearch、etcd、Nacos |
+| 不支持 SQL 文件执行 | Redis、MongoDB、Elasticsearch、Easysearch、etcd、Nacos |
如果某个数据库可以连接,但没有出现在某个功能行中,该功能可能会被隐藏或受限。这是为了避免 DBX 生成无法可靠审查的 SQL。
@@ -116,7 +116,13 @@ DBX 只会在具备足够元数据和 SQL 生成能力的数据库上启用高
大多数网络数据库支持主机、端口、用户名、密码、默认数据库、URL 参数、SSL、连接颜色和隧道/代理设置。文件型数据库会改为选择本地文件。
-DBX 也可以解析常见连接 URL,例如 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、Nacos、RQLite 和 Databricks。
+DBX 也可以解析常见连接 URL,例如 MySQL、PostgreSQL、Redis、MongoDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、DM、GaussDB、KWDB、openGauss、TDengine、XuguDB、Access、Teradata、Vertica、Firebird、Exasol、GBase、YashanDB、SAP HANA、InfluxDB、QuestDB、IoTDB、etcd、Nacos、RQLite 和 Databricks。
+
+### Easysearch
+
+DBX 提供独立的 Easysearch 连接类型,默认端口为 `9200`,通过 Elasticsearch 兼容的 HTTP REST API 连接。它复用 DBX 已有的 Elasticsearch 文档引擎,支持索引与文档浏览、Query DSL、REST 请求、常用 SQL 查询、分页与计数、文档增删改以及批量写入流程。
+
+Easysearch 官方兼容基线面向 Elasticsearch 7.10 API 和生态,但这不表示所有 Elasticsearch 版本、X-Pack、ES|QL 或其他专有扩展都完全等价。HTTPS 实例请启用 SSL;使用自签名证书的测试环境也走同一 TLS 连接路径。
### Cloudflare D1
diff --git a/docs/content/docs/databases.mdx b/docs/content/docs/databases.mdx
index 95f5d57d99..dc75681b3b 100644
--- a/docs/content/docs/databases.mdx
+++ b/docs/content/docs/databases.mdx
@@ -12,7 +12,7 @@ The connection dialog exposes ready-to-use profiles with default ports and drive
| Group | Profiles | Notes |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Core SQL engines | MySQL, PostgreSQL, SQLite, DuckDB, ClickHouse, SQL Server, Oracle, Redshift, DM, GaussDB, KWDB, Teradata, Vertica, Exasol, Firebird, SAP HANA, YashanDB, GBase | Available directly in the DBX connection picker |
-| Document, key-value, vector, and config services | Redis, MongoDB, Elasticsearch, Qdrant, Milvus, Weaviate, ChromaDB, etcd, Apache ZooKeeper, Nacos | Use dedicated browsers, admin consoles, or query flows instead of the generic relational grid where appropriate |
+| Document, key-value, vector, and config services | Redis, MongoDB, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, ChromaDB, etcd, Apache ZooKeeper, Nacos | Use dedicated browsers, admin consoles, or query flows instead of the generic relational grid where appropriate |
| 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 |
@@ -31,7 +31,7 @@ The connection dialog exposes ready-to-use profiles with default ports and drive
| ClickHouse | 8123 |
| SQL Server | 1433 |
| Oracle | 1521 |
-| Elasticsearch | 9200 |
+| Elasticsearch / Easysearch | 9200 |
| Qdrant | 6333 |
| Milvus | 19530 |
| Weaviate | 8080 |
@@ -108,7 +108,7 @@ DBX intentionally enables advanced workflows only where the app has enough metad
| Create database | MySQL, PostgreSQL, SQL Server, ClickHouse, Oracle, DM, GaussDB, KWDB, Doris, StarRocks, Redshift, Teradata, Vertica |
| Field lineage | MySQL, PostgreSQL, SQLite, Cloudflare D1, SQL Server, Oracle, Redshift, DM, GaussDB, KWDB |
| Data transfer | MySQL, PostgreSQL, SQLite, Cloudflare D1, SQL Server, Oracle, ClickHouse, DuckDB, DM, GaussDB, KWDB |
-| SQL file execution unavailable | Redis, MongoDB, Elasticsearch, etcd, Nacos |
+| SQL file execution unavailable | Redis, MongoDB, Elasticsearch, Easysearch, etcd, Nacos |
If a database can connect but does not appear in a feature row, that feature may be hidden or limited for that engine. This prevents DBX from generating SQL it cannot review reliably.
@@ -116,7 +116,13 @@ 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, and tunnel / 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, QuestDB, IoTDB, etcd, Nacos, RQLite, and Databricks.
+DBX can also parse common connection URLs for engines such as MySQL, PostgreSQL, Redis, MongoDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, DM, GaussDB, KWDB, openGauss, TDengine, XuguDB, Access, Teradata, Vertica, Firebird, Exasol, GBase, YashanDB, SAP HANA, InfluxDB, QuestDB, IoTDB, etcd, Nacos, RQLite, and Databricks.
+
+### Easysearch
+
+DBX provides a dedicated Easysearch connection type on port `9200` and connects through its Elasticsearch-compatible HTTP REST API. It reuses DBX's existing Elasticsearch document engine for index and document browsing, Query DSL, REST requests, common SQL queries, pagination and counts, document CRUD, and bulk write flows.
+
+Easysearch documents compatibility with the Elasticsearch 7.10 API and ecosystem, but this does not imply complete equivalence with every Elasticsearch version, X-Pack feature, ES|QL, or other proprietary extension. Enable SSL for HTTPS endpoints; self-signed test deployments use the same TLS connection path.
### Cloudflare D1
diff --git a/docs/content/docs/getting-started.cn.mdx b/docs/content/docs/getting-started.cn.mdx
index b1773e3fb7..39dda4cfc4 100644
--- a/docs/content/docs/getting-started.cn.mdx
+++ b/docs/content/docs/getting-started.cn.mdx
@@ -155,7 +155,7 @@ description: 安装 DBX、创建第一个连接,并了解桌面版、Docker
### 有 URL 时直接粘贴
- DBX 可以解析常见连接 URL,例如 MySQL、PostgreSQL、Redis、MongoDB、ClickHouse、SQL Server、Oracle、Elasticsearch、DM、GaussDB、openGauss、TDengine 和 Access。保存前请检查解析出的字段。
+ DBX 可以解析常见连接 URL,例如 MySQL、PostgreSQL、Redis、MongoDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、DM、GaussDB、openGauss、TDengine 和 Access。保存前请检查解析出的字段。
diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/getting-started.mdx
index 4a2389132b..ffa5dcd95a 100644
--- a/docs/content/docs/getting-started.mdx
+++ b/docs/content/docs/getting-started.mdx
@@ -149,7 +149,7 @@ This guide gets you through three things:
### Paste A URL When Available
- DBX can parse common connection URLs for engines such as MySQL, PostgreSQL, Redis, MongoDB, ClickHouse, SQL Server, Oracle, Elasticsearch, DM, GaussDB, openGauss, TDengine, and Access. Review the parsed fields before saving.
+ DBX can parse common connection URLs for engines such as MySQL, PostgreSQL, Redis, MongoDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, DM, GaussDB, openGauss, TDengine, and Access. Review the parsed fields before saving.
diff --git a/docs/content/docs/schema-diff.cn.mdx b/docs/content/docs/schema-diff.cn.mdx
index e1d5a92b01..f7f0d5ea90 100644
--- a/docs/content/docs/schema-diff.cn.mdx
+++ b/docs/content/docs/schema-diff.cn.mdx
@@ -62,7 +62,7 @@ Schema 对比用于检查两个数据库环境之间的结构差异,例如开
## 数据库覆盖范围
-Schema 对比依赖表、字段和索引元数据。它更适合 MySQL、PostgreSQL、SQLite、SQL Server、Oracle、Redshift、DM、GaussDB 系等可以稳定读取关系结构的数据库。Redis、MongoDB、Elasticsearch 的数据模型不同,不适合作为关系型 Schema 同步目标。
+Schema 对比依赖表、字段和索引元数据。它更适合 MySQL、PostgreSQL、SQLite、SQL Server、Oracle、Redshift、DM、GaussDB 系等可以稳定读取关系结构的数据库。Redis、MongoDB、Elasticsearch、Easysearch 的数据模型不同,不适合作为关系型 Schema 同步目标。
## 执行前检查
diff --git a/docs/content/docs/schema-diff.mdx b/docs/content/docs/schema-diff.mdx
index 9abd673783..28e1a488ee 100644
--- a/docs/content/docs/schema-diff.mdx
+++ b/docs/content/docs/schema-diff.mdx
@@ -62,7 +62,7 @@ Schema Diff helps compare structure between database environments, such as devel
## Database Coverage
-Schema Diff depends on table, column, and index metadata. It works best on relational engines where DBX can list structures consistently, such as MySQL, PostgreSQL, SQLite, SQL Server, Oracle, Redshift, DM, and GaussDB-family engines. Redis, MongoDB, and Elasticsearch use different data models and are not good targets for relational schema sync.
+Schema Diff depends on table, column, and index metadata. It works best on relational engines where DBX can list structures consistently, such as MySQL, PostgreSQL, SQLite, SQL Server, Oracle, Redshift, DM, and GaussDB-family engines. Redis, MongoDB, Elasticsearch, and Easysearch use different data models and are not good targets for relational schema sync.
## Before Running Sync SQL
diff --git a/docs/content/docs/sql-file.cn.mdx b/docs/content/docs/sql-file.cn.mdx
index 0a93d920ce..e1a99727ab 100644
--- a/docs/content/docs/sql-file.cn.mdx
+++ b/docs/content/docs/sql-file.cn.mdx
@@ -54,7 +54,7 @@ DBX 会按流式方式读取 SQL 文件,并尽量正确识别语句边界,
## 数据库覆盖范围
-SQL 文件执行面向 SQL 类数据库。Redis、MongoDB 和 Elasticsearch 不执行关系型 SQL 脚本,因此 DBX 会隐藏或禁用该功能。
+SQL 文件执行面向 SQL 类数据库。Redis、MongoDB、Elasticsearch 和 Easysearch 不执行关系型 SQL 脚本,因此 DBX 会隐藏或禁用该功能。
## 错误处理
diff --git a/docs/content/docs/sql-file.mdx b/docs/content/docs/sql-file.mdx
index 9a8cf413d8..695331dbfa 100644
--- a/docs/content/docs/sql-file.mdx
+++ b/docs/content/docs/sql-file.mdx
@@ -54,7 +54,7 @@ DBX reads SQL files as a stream and tries to detect statement boundaries correct
## Database Coverage
-SQL file execution is for SQL-oriented engines. DBX hides or disables it for Redis, MongoDB, and Elasticsearch because those connections do not execute relational SQL scripts.
+SQL file execution is for SQL-oriented engines. DBX hides or disables it for Redis, MongoDB, Elasticsearch, and Easysearch because those connections do not execute relational SQL scripts.
## Error Handling
diff --git a/docs/public/icons/database/easysearch.svg b/docs/public/icons/database/easysearch.svg
new file mode 100644
index 0000000000..16640f0236
--- /dev/null
+++ b/docs/public/icons/database/easysearch.svg
@@ -0,0 +1,26 @@
+
+
diff --git a/packages/app-tests/connectionUrl.test.ts b/packages/app-tests/connectionUrl.test.ts
index 9baf970e88..4a50512c79 100644
--- a/packages/app-tests/connectionUrl.test.ts
+++ b/packages/app-tests/connectionUrl.test.ts
@@ -458,6 +458,18 @@ test("uses selected HTTP-compatible profile for HTTP URLs", () => {
assert.equal(parsed.ssl, true);
});
+test("parses Easysearch URLs and keeps the selected HTTPS profile", () => {
+ const dedicated = parseConnectionUrl("easysearch://dbx_test:secret@search.example.com:9200");
+ const https = parseConnectionUrl("https://search.example.com:9243", "easysearch");
+
+ assert.equal(dedicated.dbType, "easysearch");
+ assert.equal(dedicated.driverProfile, "easysearch");
+ assert.equal(dedicated.username, "dbx_test");
+ assert.equal(https.dbType, "easysearch");
+ assert.equal(https.port, 9243);
+ assert.equal(https.ssl, true);
+});
+
test("parses HTTPS ClickHouse URLs with selected profile", () => {
const parsed = parseConnectionUrl("https://default:secret@clickhouse.example.com:8443/default?secure=true", "clickhouse");
diff --git a/packages/app-tests/documentStoreProvider.test.ts b/packages/app-tests/documentStoreProvider.test.ts
index 3997ed9549..c96c284266 100644
--- a/packages/app-tests/documentStoreProvider.test.ts
+++ b/packages/app-tests/documentStoreProvider.test.ts
@@ -31,9 +31,10 @@ function rule(patch: Partial): DocumentFilterRule {
};
}
-test("selects MongoDB and Elasticsearch document store providers", () => {
+test("selects MongoDB and Elasticsearch-compatible document store providers", () => {
assert.equal(documentStoreProviderFor("mongodb").kind, "mongodb");
assert.equal(documentStoreProviderFor("elasticsearch").kind, "elasticsearch");
+ assert.equal(documentStoreProviderFor("easysearch").kind, "elasticsearch");
});
test("providers build store-specific query previews", () => {
diff --git a/packages/app-tests/elasticsearchJsonResponse.test.ts b/packages/app-tests/elasticsearchJsonResponse.test.ts
index 768dae2d77..162ffb5c47 100644
--- a/packages/app-tests/elasticsearchJsonResponse.test.ts
+++ b/packages/app-tests/elasticsearchJsonResponse.test.ts
@@ -36,6 +36,15 @@ test("classifies Elasticsearch HEAD responses", () => {
});
});
+test("classifies Easysearch REST responses", () => {
+ const response = jsonResponse({ rows: [[200, '{"status":"green"}']] });
+
+ assert.deepEqual(elasticsearchJsonResponseForResult("easysearch", "GET /_cluster/health", response), {
+ status: 200,
+ body: '{"status":"green"}',
+ });
+});
+
test("classifies Elasticsearch responses after leading comments", () => {
const response = jsonResponse();
diff --git a/packages/app-tests/sqlSemanticDiagnostics.test.ts b/packages/app-tests/sqlSemanticDiagnostics.test.ts
index 11f5587564..2cdd18c8f6 100644
--- a/packages/app-tests/sqlSemanticDiagnostics.test.ts
+++ b/packages/app-tests/sqlSemanticDiagnostics.test.ts
@@ -347,8 +347,9 @@ test("skips diagnostics for MongoDB connections", () => {
assert.equal(shouldRunSqlSemanticDiagnostics("db.my_collection.find({})", 0, { databaseType: "mongodb" }), false);
});
-test("skips diagnostics for Elasticsearch connections", () => {
+test("skips diagnostics for Elasticsearch-compatible connections", () => {
assert.equal(shouldRunSqlSemanticDiagnostics("db.my_collection.find({})", 0, { databaseType: "elasticsearch" }), false);
+ assert.equal(shouldRunSqlSemanticDiagnostics("GET /_cluster/health", 0, { databaseType: "easysearch" }), false);
});
test("still runs diagnostics for SQL connections", () => {
diff --git a/packages/app-tests/tableColumnTemplates.test.ts b/packages/app-tests/tableColumnTemplates.test.ts
index 22b514a951..d7a1a9b588 100644
--- a/packages/app-tests/tableColumnTemplates.test.ts
+++ b/packages/app-tests/tableColumnTemplates.test.ts
@@ -97,6 +97,7 @@ test("limits preset field database types to create-table capable SQL structures"
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("mongodb"));
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("redis"));
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("elasticsearch"));
+ assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("easysearch"));
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("manticoresearch"));
});
diff --git a/packages/app-tests/useSqlExecution.test.ts b/packages/app-tests/useSqlExecution.test.ts
index 95021d272c..64d71568f7 100644
--- a/packages/app-tests/useSqlExecution.test.ts
+++ b/packages/app-tests/useSqlExecution.test.ts
@@ -71,6 +71,8 @@ test("distinguishes Elasticsearch REST paths from SQL template parameters", () =
assert.equal(supportsSqlTemplateParameters({ db_type: "elasticsearch" }, "GET /_search?pretty"), false);
assert.equal(supportsSqlTemplateParameters({ db_type: "elasticsearch" }, '/* request */\nPOST /orders/_search\n{"query":{"term":{"id":":id"}}}'), false);
assert.equal(supportsSqlTemplateParameters({ db_type: "elasticsearch" }, "SELECT * FROM orders WHERE customer_id = :customer_id"), true);
+ assert.equal(supportsSqlTemplateParameters({ db_type: "easysearch" }, "GET /_search?pretty"), false);
+ assert.equal(supportsSqlTemplateParameters({ db_type: "easysearch" }, "SELECT * FROM orders WHERE customer_id = :customer_id"), true);
assert.equal(supportsSqlTemplateParameters({ db_type: "mysql" }), true);
});
@@ -81,4 +83,5 @@ test("detects destructive Elasticsearch requests across a parsed request list",
assert.equal(isDangerousSql('PUT /orders/_mapping\n{"properties":{}}', "elasticsearch"), true);
assert.equal(isDangerousSql("GET /_cluster/health\n\n/* DELETE /production-index */\nGET /_cat/indices", "elasticsearch"), false);
assert.equal(isDangerousSql('DELETE /_search/scroll\n{"scroll_id":"abc"}', "elasticsearch"), false);
+ assert.equal(isDangerousSql("DELETE /production-index", "easysearch"), true);
});
diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs
index a634057618..3477e2f5a4 100644
--- a/src-tauri/src/commands/connection.rs
+++ b/src-tauri/src/commands/connection.rs
@@ -922,6 +922,20 @@ async fn test_connection_with_info_inner(
.await
.map(|_| "Connection successful".to_string())
}
+ DatabaseType::Easysearch => {
+ let mut client = db::easysearch_driver::EasysearchClient::from_config(
+ &url,
+ Some(&config.username),
+ Some(&config.password),
+ config.ssl,
+ config.url_params.as_deref(),
+ config.external_config.as_ref(),
+ connect_timeout,
+ );
+ db::easysearch_driver::test_connection(&mut client, connect_timeout)
+ .await
+ .map(|_| "Connection successful".to_string())
+ }
DatabaseType::Hbase => {
let client = db::hbase_driver::HBaseClient::new(
&url,
@@ -1243,6 +1257,19 @@ pub async fn connect_db(
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;
PoolKind::Elasticsearch(client)
}
+ DatabaseType::Easysearch => {
+ let mut client = db::easysearch_driver::EasysearchClient::from_config(
+ &url,
+ Some(&db_config.username),
+ Some(&db_config.password),
+ db_config.ssl,
+ db_config.url_params.as_deref(),
+ db_config.external_config.as_ref(),
+ connect_timeout,
+ );
+ db::easysearch_driver::test_connection(&mut client, connect_timeout).await?;
+ PoolKind::Easysearch(client)
+ }
DatabaseType::Hbase => {
let client = db::hbase_driver::HBaseClient::new(
&url,
diff --git a/src-tauri/src/commands/mcp_bridge.rs b/src-tauri/src/commands/mcp_bridge.rs
index d5ad033313..f42596ab75 100644
--- a/src-tauri/src/commands/mcp_bridge.rs
+++ b/src-tauri/src/commands/mcp_bridge.rs
@@ -368,6 +368,7 @@ mod tests {
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::MongoDb).is_err());
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Redis).is_err());
assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Elasticsearch).is_err());
+ assert!(ensure_mcp_execute_and_show_supported(&DatabaseType::Easysearch).is_err());
}
#[test]