mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(meilisearch): add native document database support
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
<img src="https://img.shields.io/badge/SQL%20Server-CC2927?logo=microsoftsqlserver&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
@@ -90,7 +91,7 @@
|
||||
|
||||
### 70+ Databases, One Tool
|
||||
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
@@ -483,7 +484,7 @@ DBX is 20 MB with no runtime dependencies for its native database features (no s
|
||||
|
||||
<details>
|
||||
<summary><strong>What databases are supported?</strong></summary>
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent-based profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent-based profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
+3
-2
@@ -27,6 +27,7 @@
|
||||
<img src="https://img.shields.io/badge/SQL%20Server-CC2927?logo=microsoftsqlserver&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
@@ -90,7 +91,7 @@
|
||||
|
||||
### 70+ 种数据库,一个工具搞定
|
||||
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
@@ -486,7 +487,7 @@ DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和
|
||||
|
||||
<details>
|
||||
<summary><strong>支持哪些数据库?</strong></summary>
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Meilisearch">
|
||||
<defs>
|
||||
<linearGradient id="m" x1="8" y1="56" x2="56" y2="8" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5CAA"/>
|
||||
<stop offset="1" stop-color="#FFB03A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#m)" d="M10 47.5 18.5 17a5 5 0 0 1 9.2-1.2L32 23l4.3-7.2a5 5 0 0 1 9.2 1.2L54 47.5a4.5 4.5 0 0 1-8.6 2.6L40.2 31l-4.3 7.1a4.5 4.5 0 0 1-7.8 0L23.8 31l-5.2 19.1a4.5 4.5 0 0 1-8.6-2.6Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 519 B |
@@ -2037,7 +2037,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
await connectionStore.loadConsulRoot(item.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
|
||||
await connectionStore.openElasticsearchConnectionTree(item.connectionId);
|
||||
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
|
||||
await connectionStore.loadVectorCollections(item.connectionId);
|
||||
@@ -2064,7 +2064,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
await connectionStore.loadConsulRoot(item.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(item.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
|
||||
await connectionStore.openElasticsearchConnectionTree(item.connectionId);
|
||||
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
|
||||
await connectionStore.loadVectorCollections(item.connectionId);
|
||||
|
||||
@@ -1045,6 +1045,13 @@ const driverProfiles: Record<
|
||||
label: "Easysearch",
|
||||
icon: "easysearch",
|
||||
},
|
||||
meilisearch: {
|
||||
type: "meilisearch",
|
||||
port: 7700,
|
||||
user: "",
|
||||
label: "Meilisearch",
|
||||
icon: "meilisearch",
|
||||
},
|
||||
hbase: { type: "hbase", port: 8080, user: "", label: "Apache HBase", icon: "hbase" },
|
||||
qdrant: { type: "qdrant", port: 6333, user: "", label: "Qdrant", icon: "qdrant" },
|
||||
milvus: { type: "milvus", port: 19530, user: "root", label: "Milvus", icon: "milvus" },
|
||||
@@ -2707,6 +2714,7 @@ const iconTypeMap: Record<string, string> = {
|
||||
oracle: "oracle",
|
||||
elasticsearch: "elasticsearch",
|
||||
easysearch: "easysearch",
|
||||
meilisearch: "meilisearch",
|
||||
hbase: "hbase",
|
||||
qdrant: "qdrant",
|
||||
milvus: "milvus",
|
||||
@@ -2793,6 +2801,7 @@ const dbOptions: DbOption[] = [
|
||||
{ value: "sqlserver", label: "SQL Server" },
|
||||
{ value: "elasticsearch", label: "Elasticsearch" },
|
||||
{ value: "easysearch", label: "Easysearch" },
|
||||
{ value: "meilisearch", label: "Meilisearch" },
|
||||
{ value: "hbase", label: "Apache HBase" },
|
||||
{ value: "qdrant", label: "Qdrant" },
|
||||
{ value: "milvus", label: "Milvus" },
|
||||
@@ -2900,7 +2909,7 @@ const dbCategoryDefinitions: Array<{
|
||||
{
|
||||
key: "document",
|
||||
titleKey: "connection.databaseCategoryDocument",
|
||||
optionValues: ["mongodb", "redis", "elasticsearch", "easysearch", "hbase", "manticoresearch", "cassandra"],
|
||||
optionValues: ["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "hbase", "manticoresearch", "cassandra"],
|
||||
},
|
||||
{
|
||||
key: "graph_ai",
|
||||
@@ -3040,6 +3049,7 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
|
||||
"clickhouse",
|
||||
"elasticsearch",
|
||||
"easysearch",
|
||||
"meilisearch",
|
||||
"hbase",
|
||||
"qdrant",
|
||||
"milvus",
|
||||
@@ -3841,6 +3851,11 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
config.username = config.username.trim();
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value, elasticsearchIndexGroupingPattern.value);
|
||||
} else if (config.db_type === "meilisearch") {
|
||||
config.username = "";
|
||||
config.password = config.password.trim();
|
||||
config.database = undefined;
|
||||
config.external_config = undefined;
|
||||
} else if (config.db_type === "sqlserver") {
|
||||
config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined;
|
||||
} else if (supportsGaussdbIdentifierQuoteStyle(config)) {
|
||||
@@ -5570,7 +5585,7 @@ function openExternalUrl(url: string) {
|
||||
@dblclick="goToConnectionStep(opt.value)"
|
||||
>
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-muted/60 transition group-hover:bg-background">
|
||||
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="h-6 w-6" />
|
||||
<DatabaseIcon :db-type="iconTypeMap[opt.value] || opt.value" class="h-6 w-6" />
|
||||
</span>
|
||||
<span class="flex min-h-8 max-w-full items-center justify-center">
|
||||
<span class="line-clamp-2 text-sm leading-4 font-medium">{{ opt.label }}</span>
|
||||
@@ -5589,7 +5604,7 @@ function openExternalUrl(url: string) {
|
||||
@click="onDbTypeChange(opt.value)"
|
||||
@dblclick="goToConnectionStep(opt.value)"
|
||||
>
|
||||
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="h-5 w-5 shrink-0" />
|
||||
<DatabaseIcon :db-type="iconTypeMap[opt.value] || opt.value" class="h-5 w-5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ opt.label }}</span>
|
||||
<span v-if="isDbSearchActive" class="text-xs text-muted-foreground">{{ category.title }}</span>
|
||||
</button>
|
||||
@@ -6956,13 +6971,13 @@ function openExternalUrl(url: string) {
|
||||
<Input v-model="form.informix_server" class="col-span-3" placeholder="ol_informix1170" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="form.db_type !== 'meilisearch'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
|
||||
<Label :class="connectionLabelClass">{{ form.db_type === "meilisearch" ? t("connection.mqAuthApiKey") : t("connection.password") }}</Label>
|
||||
<PasswordInput v-model="form.password" class="col-span-3" />
|
||||
</div>
|
||||
|
||||
@@ -6975,7 +6990,7 @@ function openExternalUrl(url: string) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type !== 'hbase'" class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="form.db_type !== 'hbase' && form.db_type !== 'meilisearch'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ databaseLabel }}</Label>
|
||||
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
|
||||
</div>
|
||||
|
||||
@@ -118,7 +118,7 @@ const showModified = ref(true);
|
||||
|
||||
let syncPlanRequestId = 0;
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
const filteredSourceTables = computed(() => {
|
||||
|
||||
@@ -58,7 +58,7 @@ const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
flattenDocumentFieldPathTree,
|
||||
searchDocumentFieldPathTree,
|
||||
documentFilterModeNeedsValue,
|
||||
documentFilterModeOptions,
|
||||
documentFilterModeOptionsFor,
|
||||
documentFilterValueTypeOptions,
|
||||
documentStoreProviderFor,
|
||||
elasticsearchBoolClauseOptions,
|
||||
@@ -60,12 +60,13 @@ import {
|
||||
parseDocumentStoreInputValue,
|
||||
parseDocumentStoreJsonDocument,
|
||||
planDocumentStoreIdentityMigration,
|
||||
prepareDocumentStoreWriteDocument,
|
||||
resolveDocumentStoreWriteRouting,
|
||||
serializeDocumentStoreId,
|
||||
stringifyDocumentStoreValue,
|
||||
documentStoreValueForGrid,
|
||||
} from "@/lib/app/documentJsonValues";
|
||||
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
|
||||
import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import {
|
||||
@@ -299,8 +300,8 @@ const deleteDetails = computed(() => {
|
||||
if (pending.kind === "document") {
|
||||
const id = documents.value[pending.index]?._id ?? "";
|
||||
const displayId = mongoDocumentIdForGrid(id);
|
||||
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
|
||||
const product = props.databaseType === "easysearch" ? "Easysearch" : "Elasticsearch";
|
||||
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch") {
|
||||
const product = props.databaseType === "easysearch" ? "Easysearch" : props.databaseType === "meilisearch" ? "Meilisearch" : "Elasticsearch";
|
||||
return `${product} index: ${props.collection}\nDocument _id: ${String(displayId)}`;
|
||||
}
|
||||
return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(displayId) });
|
||||
@@ -335,7 +336,7 @@ const gridResult = computed<QueryResult>(() => {
|
||||
columns.map((col) => {
|
||||
const val = mongoDocumentDisplayValue(doc[col]);
|
||||
if (val === undefined || val === null) return null;
|
||||
if (col === "_id") return documentStoreProvider.value.kind === "elasticsearch" ? documentStoreValueForGrid(val, "elasticsearch") : mongoDocumentIdForGrid(val);
|
||||
if (col === "_id") return documentStoreProvider.value.kind === "mongodb" ? mongoDocumentIdForGrid(val) : documentStoreValueForGrid(val, documentStoreProvider.value.kind);
|
||||
if (typeof val === "object") return documentStoreValueForGrid(val, documentStoreProvider.value.kind);
|
||||
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") return val;
|
||||
return String(val);
|
||||
@@ -437,6 +438,7 @@ const documentStructuredFilterCount = computed(() => {
|
||||
return count + (Array.isArray(rules) ? rules.length : 0);
|
||||
}, 0);
|
||||
});
|
||||
const currentDocumentFilterModeOptions = computed(() => documentFilterModeOptionsFor(documentStoreProvider.value.kind));
|
||||
const documentLoadingLabelKey = computed(() => (documentLoadCancelling.value ? "common.stopping" : "common.loading"));
|
||||
let documentLoadingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
@@ -765,28 +767,60 @@ async function gridSave(changes: DocumentGridChanges) {
|
||||
const cols = changes.columns;
|
||||
const idColIdx = cols.indexOf("_id");
|
||||
if (idColIdx < 0) throw new Error("No _id column");
|
||||
const isEs = documentStoreProvider.value.kind === "elasticsearch";
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
const isPathIdentityStore = kind !== "mongodb";
|
||||
const isEs = kind === "elasticsearch";
|
||||
|
||||
if (kind === "meilisearch") {
|
||||
const updates: Array<{ id: string; docJson: string }> = [];
|
||||
const deleteIds: string[] = [];
|
||||
const inserts: string[] = [];
|
||||
|
||||
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
|
||||
const row = changes.rows[rowIdx];
|
||||
const id = row?.[idColIdx];
|
||||
const doc = documents.value[rowIdx];
|
||||
if (id == null || !doc) continue;
|
||||
const updated = buildPathIdentityUpdatedDocument(doc, dirtyCols, cols, kind);
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
|
||||
updates.push({
|
||||
id: serializeDocumentStoreId(doc._id ?? id, kind),
|
||||
docJson: stringifyDocumentStoreValue(writeDocument, kind),
|
||||
});
|
||||
}
|
||||
|
||||
for (const rowIdx of changes.deletedRows) {
|
||||
const row = changes.rows[rowIdx];
|
||||
const id = row?.[idColIdx];
|
||||
if (id == null) continue;
|
||||
deleteIds.push(serializeDocumentStoreId(documents.value[rowIdx]?._id ?? id, kind));
|
||||
}
|
||||
|
||||
for (const newRow of changes.newRows) {
|
||||
const doc = buildPathIdentityInsertDocument(newRow, cols, kind);
|
||||
const idValue = newRow[idColIdx];
|
||||
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, kind);
|
||||
inserts.push(stringifyDocumentStoreValue(doc, kind));
|
||||
}
|
||||
|
||||
await api.documentSaveMeilisearchBatch(props.connectionId, props.collection, updates, deleteIds, inserts);
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
|
||||
const row = changes.rows[rowIdx];
|
||||
const id = row?.[idColIdx];
|
||||
if (id == null) continue;
|
||||
|
||||
if (isEs) {
|
||||
if (isPathIdentityStore) {
|
||||
const doc = documents.value[rowIdx];
|
||||
if (!doc) continue;
|
||||
const routing = documentRoutingFromDocument(doc);
|
||||
const updated = { ...doc };
|
||||
for (const [colIdx, newVal] of dirtyCols) {
|
||||
const col = cols[colIdx];
|
||||
if (col === "_id" || col === "_routing") continue;
|
||||
if (newVal === null) {
|
||||
delete updated[col];
|
||||
} else {
|
||||
updated[col] = parseDocumentStoreInputValue(newVal, "elasticsearch");
|
||||
}
|
||||
}
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, String(id), stringifyDocumentStoreValue(updated, "elasticsearch"), routing);
|
||||
const routing = isEs ? documentRoutingFromDocument(doc) : undefined;
|
||||
const updated = buildPathIdentityUpdatedDocument(doc, dirtyCols, cols, kind);
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
|
||||
const documentId = serializeDocumentStoreId(doc._id ?? id, kind);
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind), routing);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -803,20 +837,21 @@ async function gridSave(changes: DocumentGridChanges) {
|
||||
const document = documents.value[rowIdx];
|
||||
const routing = isEs ? documentRoutingFromDocument(document) : undefined;
|
||||
const documentType = isEs ? documentTypeFromDocument(document) : undefined;
|
||||
const documentId = isEs ? id : (document?._id ?? id);
|
||||
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, isEs ? String(documentId) : serializeMongoDocumentId(documentId), routing, documentType);
|
||||
const documentId = document?._id ?? id;
|
||||
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, isPathIdentityStore ? serializeDocumentStoreId(documentId, kind) : serializeMongoDocumentId(documentId), routing, documentType);
|
||||
}
|
||||
|
||||
for (const [newRowIndex, newRow] of changes.newRows.entries()) {
|
||||
const newRowMeta = changes.newRowMeta[newRowIndex];
|
||||
const doc = isEs ? buildElasticsearchInsertDocument(newRow, cols) : buildMongoGridInsertDocument(newRow, cols, newRowMeta);
|
||||
if (isEs) {
|
||||
const id = documentIdFromGridValue(newRow[idColIdx]);
|
||||
const routing = documentRoutingFromGridRow(newRow, cols);
|
||||
const doc = isPathIdentityStore ? buildPathIdentityInsertDocument(newRow, cols, kind) : buildMongoGridInsertDocument(newRow, cols, newRowMeta);
|
||||
if (isPathIdentityStore) {
|
||||
const idValue = newRow[idColIdx];
|
||||
const id = idValue === null || idValue === undefined || idValue === "" ? null : serializeDocumentStoreId(parseDocumentStoreInputValue(idValue, kind), kind);
|
||||
const routing = isEs ? documentRoutingFromGridRow(newRow, cols) : undefined;
|
||||
if (id) {
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, "elasticsearch"), routing);
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, kind), routing);
|
||||
} else {
|
||||
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, "elasticsearch"), routing);
|
||||
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, kind), routing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -829,17 +864,28 @@ async function gridSave(changes: DocumentGridChanges) {
|
||||
await load();
|
||||
}
|
||||
|
||||
function buildElasticsearchInsertDocument(row: MongoInputValue[], columns: string[]): JsonRecord {
|
||||
function buildPathIdentityInsertDocument(row: MongoInputValue[], columns: string[], kind: Exclude<DocumentStoreKind, "mongodb">): JsonRecord {
|
||||
const doc: JsonRecord = {};
|
||||
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
|
||||
const column = columns[columnIndex];
|
||||
if (!column || column === "_id" || column === "_routing") continue;
|
||||
if (!column || column === "_id" || (kind === "elasticsearch" && column === "_routing")) continue;
|
||||
const value = row[columnIndex];
|
||||
if (value !== null) doc[column] = parseDocumentStoreInputValue(value, "elasticsearch");
|
||||
if (value !== null) doc[column] = parseDocumentStoreInputValue(value, kind);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
function buildPathIdentityUpdatedDocument(document: JsonRecord, changes: Map<number, MongoInputValue>, columns: string[], kind: Exclude<DocumentStoreKind, "mongodb">): JsonRecord {
|
||||
const updated = { ...document };
|
||||
for (const [columnIndex, newValue] of changes) {
|
||||
const column = columns[columnIndex];
|
||||
if (!column || column === "_id" || (kind === "elasticsearch" && column === "_routing")) continue;
|
||||
if (newValue === null) delete updated[column];
|
||||
else updated[column] = parseDocumentStoreInputValue(newValue, kind);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function buildMongoGridInsertDocument(row: MongoInputValue[], columns: string[], meta?: GridNewRowMeta): Record<string, unknown> {
|
||||
const sourceIndex = meta?.sourceIndex;
|
||||
const sourceDocument = sourceIndex === undefined ? undefined : copyDocuments.value[sourceIndex];
|
||||
@@ -879,16 +925,27 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
|
||||
const idColIdx = columns.indexOf("_id");
|
||||
const stmts: string[] = [];
|
||||
const coll = props.collection;
|
||||
const isEs = documentStoreProvider.value.kind === "elasticsearch";
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
const isPathIdentityStore = kind !== "mongodb";
|
||||
const isEs = kind === "elasticsearch";
|
||||
|
||||
for (const [rowIdx, dirtyCols] of dirtyRows) {
|
||||
const row = rows[rowIdx];
|
||||
const id = row?.[idColIdx];
|
||||
if (id == null) continue;
|
||||
if (isEs) {
|
||||
const updateDoc = buildElasticsearchPartialUpdateDocument(dirtyCols, columns);
|
||||
const routing = documentRoutingFromGridRow(row, columns);
|
||||
stmts.push(`POST /${coll}/_update/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}\n${stringifyDocumentStoreValue({ doc: updateDoc.$set ?? updateDoc }, "elasticsearch", 2)}`);
|
||||
if (isPathIdentityStore) {
|
||||
if (isEs) {
|
||||
const updateDoc = buildElasticsearchPartialUpdateDocument(dirtyCols, columns);
|
||||
const routing = documentRoutingFromGridRow(row, columns);
|
||||
stmts.push(`POST /${coll}/_update/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}\n${stringifyDocumentStoreValue({ doc: updateDoc.$set ?? updateDoc }, "elasticsearch", 2)}`);
|
||||
} else {
|
||||
const sourceDocument = documents.value[rowIdx];
|
||||
if (!sourceDocument) continue;
|
||||
const documentId = sourceDocument._id ?? id;
|
||||
const updated = buildPathIdentityUpdatedDocument(sourceDocument, dirtyCols, columns, "meilisearch");
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind: "meilisearch", mode: "update" });
|
||||
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "update", index: coll, id: documentId, document: writeDocument }));
|
||||
}
|
||||
} else {
|
||||
const updateDoc = buildMongoUpdateDocument(dirtyCols, columns, documents.value[rowIdx]);
|
||||
stmts.push(`db.${coll}.updateOne({_id: ${formatMongoShellLiteral(documents.value[rowIdx]?._id ?? id)}}, ${formatMongoShellLiteral(updateDoc)})`);
|
||||
@@ -899,7 +956,11 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
|
||||
const row = rows[rowIdx];
|
||||
const id = row?.[idColIdx];
|
||||
if (id == null) continue;
|
||||
if (isEs) {
|
||||
if (isPathIdentityStore) {
|
||||
if (!isEs) {
|
||||
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "delete", index: coll, id: documents.value[rowIdx]?._id ?? id }));
|
||||
continue;
|
||||
}
|
||||
const routing = documentRoutingFromGridRow(row, columns);
|
||||
stmts.push(`DELETE /${coll}/_doc/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}`);
|
||||
} else {
|
||||
@@ -908,8 +969,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
|
||||
}
|
||||
|
||||
for (const [newRowIndex, newRow] of newRows.entries()) {
|
||||
const doc = isEs ? buildElasticsearchInsertDocument(newRow, columns) : buildMongoGridInsertDocument(newRow, columns, newRowMeta[newRowIndex]);
|
||||
if (isEs) {
|
||||
const doc = isPathIdentityStore ? buildPathIdentityInsertDocument(newRow, columns, kind) : buildMongoGridInsertDocument(newRow, columns, newRowMeta[newRowIndex]);
|
||||
if (isPathIdentityStore) {
|
||||
if (!isEs) {
|
||||
const idValue = idColIdx >= 0 ? newRow[idColIdx] : null;
|
||||
const id = idValue === null || idValue === undefined || idValue === "" ? undefined : parseDocumentStoreInputValue(idValue, "meilisearch");
|
||||
stmts.push(formatMeilisearchDocumentOperationPreview({ action: id === undefined ? "insert" : "upsert", index: coll, id, document: doc }));
|
||||
continue;
|
||||
}
|
||||
const id = idColIdx >= 0 ? documentIdFromGridValue(newRow[idColIdx]) : null;
|
||||
if (id) {
|
||||
stmts.push(`PUT /${coll}/_doc/${elasticsearchPathIdPreview(id)}\n${stringifyDocumentStoreValue(doc, "elasticsearch", 2)}`);
|
||||
@@ -1515,7 +1582,7 @@ async function saveDoc() {
|
||||
|
||||
if (isNew.value) {
|
||||
const apis = documentStoreWriteApis();
|
||||
const explicitId = kind === "elasticsearch" ? documentIdFromGridValue(documentStoreValueForGrid(doc._id, "elasticsearch")) : null;
|
||||
const explicitId = kind === "mongodb" || doc._id === undefined || doc._id === null || doc._id === "" ? null : resolveDocumentStorePathId(doc._id);
|
||||
await insertDocumentStoreDocumentCore({
|
||||
kind,
|
||||
document: doc,
|
||||
@@ -1573,7 +1640,7 @@ async function saveDoc() {
|
||||
async function applyDeleteDoc(idx: number) {
|
||||
const doc = documents.value[idx];
|
||||
const id = doc._id;
|
||||
if (!id) return;
|
||||
if (id === undefined || id === null || id === "") return;
|
||||
error.value = "";
|
||||
try {
|
||||
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, serializeDocumentStoreId(id, documentStoreProvider.value.kind), documentRoutingFromDocument(doc), documentStoreProvider.value.kind === "elasticsearch" ? documentTypeFromDocument(doc) : undefined);
|
||||
@@ -2086,7 +2153,7 @@ defineExpose({ focusSearch });
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem v-for="option in documentFilterModeOptions" :key="option.value" :value="option.value">
|
||||
<SelectItem v-for="option in currentDocumentFilterModeOptions" :key="option.value" :value="option.value">
|
||||
{{ t(option.labelKey) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
+79
@@ -9,6 +9,7 @@ const backend = vi.hoisted(() => ({
|
||||
cancelQuery: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
documentDeleteDocument: vi.fn(),
|
||||
documentSaveMeilisearchBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
const documentJsonEditor = vi.hoisted(() => ({
|
||||
@@ -27,6 +28,11 @@ const dataGrid = vi.hoisted(() => ({
|
||||
| undefined
|
||||
>)
|
||||
| undefined,
|
||||
customSaveHandler: undefined as
|
||||
| {
|
||||
save: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<void>;
|
||||
}
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
const settings = vi.hoisted(() => ({
|
||||
@@ -56,6 +62,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
documentFindDocuments: backend.documentFindDocuments,
|
||||
cancelQuery: backend.cancelQuery,
|
||||
documentDeleteDocument: backend.documentDeleteDocument,
|
||||
documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
@@ -82,9 +89,11 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
database: { type: String, default: "" },
|
||||
columnLayoutScopeKey: { type: String, default: "" },
|
||||
fullExportResult: { type: Function, default: undefined },
|
||||
customSaveHandler: { type: Object, default: undefined },
|
||||
},
|
||||
setup(props, { expose, slots }) {
|
||||
dataGrid.fullExportResult = props.fullExportResult as typeof dataGrid.fullExportResult;
|
||||
dataGrid.customSaveHandler = props.customSaveHandler as typeof dataGrid.customSaveHandler;
|
||||
expose({
|
||||
visibleColumnCount: 2,
|
||||
displayableColumnCount: 2,
|
||||
@@ -278,9 +287,12 @@ beforeEach(async () => {
|
||||
backend.cancelQuery.mockReset();
|
||||
backend.ensureConnected.mockReset();
|
||||
backend.documentDeleteDocument.mockReset();
|
||||
backend.documentSaveMeilisearchBatch.mockReset();
|
||||
dataGrid.fullExportResult = undefined;
|
||||
dataGrid.customSaveHandler = undefined;
|
||||
documentJsonEditor.openSearch.mockClear();
|
||||
backend.documentDeleteDocument.mockResolvedValue(undefined);
|
||||
backend.documentSaveMeilisearchBatch.mockResolvedValue(0);
|
||||
settings.editorSettings.mongoViewMode = "table";
|
||||
settings.editorSettings.columnWidthDensity = "standard";
|
||||
settings.editorSettings.dataGridRenderMode = "canvas";
|
||||
@@ -757,4 +769,71 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
expect(backend.documentDeleteDocument).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("dangerDialog.deleteMessage");
|
||||
});
|
||||
|
||||
it("saves Meilisearch grid changes in one batch request", async () => {
|
||||
app?.unmount();
|
||||
backend.getColumns.mockResolvedValue([
|
||||
{ name: "id", data_type: "string", is_primary_key: true },
|
||||
{ name: "title", data_type: "string" },
|
||||
{ name: "rating", data_type: "number" },
|
||||
{ name: "obsolete", data_type: "boolean" },
|
||||
]);
|
||||
backend.documentFindDocuments.mockResolvedValue({
|
||||
documents: [
|
||||
{ _id: "001", title: "One", rating: 1, obsolete: true },
|
||||
{ _id: 2, title: "Two", rating: 2, obsolete: false },
|
||||
{ _id: 3, title: "Three", rating: 3, obsolete: false },
|
||||
],
|
||||
raw_documents: [],
|
||||
total: 3,
|
||||
total_is_exact: true,
|
||||
});
|
||||
backend.documentSaveMeilisearchBatch.mockResolvedValue(4);
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "meili-1",
|
||||
database: "default",
|
||||
collection: "movies",
|
||||
databaseType: "meilisearch",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
expect(dataGrid.customSaveHandler).toBeDefined();
|
||||
await dataGrid.customSaveHandler!.save({
|
||||
dirtyRows: new Map([
|
||||
[
|
||||
0,
|
||||
new Map([
|
||||
[1, "One revised"],
|
||||
[3, null],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
deletedRows: new Set([2]),
|
||||
newRows: [
|
||||
["004", "Four", 4, false],
|
||||
[null, "Generated", 5, true],
|
||||
],
|
||||
newRowMeta: [{}, {}],
|
||||
columns: ["_id", "title", "rating", "obsolete"],
|
||||
rows: [
|
||||
["001", "One", 1, true],
|
||||
[2, "Two", 2, false],
|
||||
[3, "Three", 3, false],
|
||||
],
|
||||
});
|
||||
|
||||
expect(backend.documentSaveMeilisearchBatch).toHaveBeenCalledOnce();
|
||||
const [connectionId, collection, updates, deleteIds, inserts] = backend.documentSaveMeilisearchBatch.mock.calls[0]!;
|
||||
expect(connectionId).toBe("meili-1");
|
||||
expect(collection).toBe("movies");
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].id).toBe('__dbx_meilisearch_string_id__"001"');
|
||||
expect(JSON.parse(updates[0].docJson)).toEqual({ title: "One revised", rating: 1 });
|
||||
expect(deleteIds).toEqual(["3"]);
|
||||
expect(inserts.map((value: string) => JSON.parse(value))).toEqual([
|
||||
{ title: "Four", rating: 4, obsolete: false, _id: "004" },
|
||||
{ title: "Generated", rating: 5, obsolete: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1842,7 +1842,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
|
||||
|
||||
function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean {
|
||||
const databaseType = props.databaseType;
|
||||
const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "victoriametrics" ? "text" : "sql";
|
||||
const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "victoriametrics" ? "text" : "sql";
|
||||
return extendQueryEditorSelection(currentView, {
|
||||
databaseType,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
@@ -2042,7 +2042,7 @@ function getInsertValueHintTableColumns(table: string, schema?: string, database
|
||||
|
||||
function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) {
|
||||
if (!props.connectionId || props.database == null) return;
|
||||
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "victoriametrics") return;
|
||||
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "victoriametrics") return;
|
||||
const cacheKey = insertHintCacheKey({ name: table, schema, database });
|
||||
const hasCachedColumns = props.databaseType === "sqlserver" ? cachedInsertValueHintColumnsByTable.has(cacheKey) : cachedColumnsByTable.has(cacheKey);
|
||||
if (hasCachedColumns || pendingInsertValueHintColumnLoads.has(cacheKey)) return;
|
||||
@@ -2763,7 +2763,7 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "victoriametrics") {
|
||||
if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "victoriametrics") {
|
||||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
@@ -3098,7 +3098,7 @@ function localCompletionSchemasForDatabaseDisambiguation(completionContext: Retu
|
||||
}
|
||||
|
||||
function shouldInsertSqlCompletionSpace(): boolean {
|
||||
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "victoriametrics";
|
||||
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "victoriametrics";
|
||||
}
|
||||
|
||||
function completionOptionForItem(item: QueryCompletionItem) {
|
||||
@@ -3285,6 +3285,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
if (props.databaseType === "mongodb") {
|
||||
return provideMongoCompletions(currentState, position, explicit);
|
||||
}
|
||||
if (props.databaseType === "meilisearch") return null;
|
||||
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
|
||||
if (!isSqlLikeCompletionStatement(fullDoc, position, sqlCompletionDialectOptions())) {
|
||||
return provideElasticsearchCompletions(currentState, position, explicit);
|
||||
@@ -3590,7 +3591,7 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s
|
||||
if (props.databaseType === "mongodb") {
|
||||
return !!(insertedText || removedText) && shouldAutoOpenMongoCompletion(fullDoc, position);
|
||||
}
|
||||
if (props.databaseType === "victoriametrics") return false;
|
||||
if (props.databaseType === "victoriametrics" || props.databaseType === "meilisearch") return false;
|
||||
if (props.databaseType === "redis" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
|
||||
// Preserve old character-based checks for non-SQL providers.
|
||||
if (!insertedText && removedText) {
|
||||
@@ -4787,7 +4788,8 @@ onMounted(async () => {
|
||||
sqlSignatureComp.of(buildSqlSignatureExtension()),
|
||||
diagnosticComp.of(buildSqlDiagnosticExtension()),
|
||||
createInsertValueHintsExtension({
|
||||
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "victoriametrics",
|
||||
isEnabled: () =>
|
||||
settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "victoriametrics",
|
||||
getTableColumns: getInsertValueHintTableColumns,
|
||||
requestTableColumns: requestInsertValueHintTableColumns,
|
||||
}),
|
||||
|
||||
@@ -103,7 +103,7 @@ const exportElapsedText = computed(() => {
|
||||
return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value);
|
||||
});
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const canExport = computed(() => {
|
||||
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
|
||||
|
||||
@@ -30,4 +30,27 @@ describe("DatabaseIcon", () => {
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("/icons/database/phoenix.svg");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("uses the Meilisearch asset", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(DatabaseIcon, { dbType: "meilisearch" });
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("/icons/database/meilisearch.svg");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("falls back to the generic icon when the database type is missing", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(DatabaseIcon);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { webPath } from "@/lib/common/webPath";
|
||||
|
||||
const props = defineProps<{
|
||||
dbType: string;
|
||||
dbType?: string;
|
||||
}>();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
@@ -27,6 +27,7 @@ const assetIcons: Record<string, string> = {
|
||||
tidb: "tidb",
|
||||
elasticsearch: "elasticsearch",
|
||||
easysearch: "easysearch",
|
||||
meilisearch: "meilisearch",
|
||||
oracle: "oracle",
|
||||
"oracle-10g": "oracle",
|
||||
"oracle-legacy": "oracle",
|
||||
@@ -110,7 +111,7 @@ const assetIcons: Record<string, string> = {
|
||||
dolt: "dolt",
|
||||
};
|
||||
|
||||
const normalizedType = computed(() => props.dbType.toLowerCase().replace(/[\s-]+/g, "_"));
|
||||
const normalizedType = computed(() => (props.dbType || "").toLowerCase().replace(/[\s-]+/g, "_"));
|
||||
const assetName = computed(() => assetIcons[normalizedType.value]);
|
||||
const useLightIconInDarkMode = computed(() => normalizedType.value === "easysearch" && isDark.value);
|
||||
const assetSrc = computed(() => {
|
||||
|
||||
@@ -103,6 +103,7 @@ const supportsExplain = computed(() => {
|
||||
dbType !== "mongodb" &&
|
||||
dbType !== "elasticsearch" &&
|
||||
dbType !== "easysearch" &&
|
||||
dbType !== "meilisearch" &&
|
||||
dbType !== "qdrant" &&
|
||||
dbType !== "milvus" &&
|
||||
dbType !== "weaviate" &&
|
||||
@@ -486,6 +487,7 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
v-if="
|
||||
activeConnection?.db_type !== 'elasticsearch' &&
|
||||
activeConnection?.db_type !== 'easysearch' &&
|
||||
activeConnection?.db_type !== 'meilisearch' &&
|
||||
activeConnection?.db_type !== 'qdrant' &&
|
||||
activeConnection?.db_type !== 'milvus' &&
|
||||
activeConnection?.db_type !== 'weaviate' &&
|
||||
|
||||
@@ -1161,7 +1161,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" || config.db_type === "easysearch") {
|
||||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
|
||||
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);
|
||||
|
||||
@@ -676,7 +676,7 @@ async function toggle() {
|
||||
await connectionStore.loadConsulRoot(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
|
||||
// Expand: list indices (like other db types list databases).
|
||||
await connectionStore.loadElasticsearchIndices(node.connectionId);
|
||||
} else if (config?.db_type === "milvus") {
|
||||
@@ -3534,7 +3534,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", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
|
||||
return !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
|
||||
});
|
||||
|
||||
const canOpenDiagram = computed(() => {
|
||||
|
||||
@@ -113,7 +113,7 @@ function resetPerFileState() {
|
||||
currentFileName.value = "";
|
||||
}
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
|
||||
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requiresDatabaseSelection, useSqlExecution } from "../useSqlExecution";
|
||||
import { isDangerousSql, requiresDatabaseSelection, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -115,6 +115,13 @@ describe("requiresDatabaseSelection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsSqlTemplateParameters", () => {
|
||||
it("disables SQL parameter prompts for Meilisearch input", () => {
|
||||
expect(supportsSqlTemplateParameters(connection("meilisearch"), "GET /indexes/:uid")).toBe(false);
|
||||
expect(supportsSqlTemplateParameters(connection("meilisearch"), "plain :value")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSqlExecution", () => {
|
||||
beforeEach(() => {
|
||||
installLocalStorage();
|
||||
@@ -756,6 +763,20 @@ SELECT @value AS Message;`;
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { skipRedisSafetyCheck: false, openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("distinguishes read-only and mutating Meilisearch REST requests", () => {
|
||||
expect(isDangerousSql("GET /health", "meilisearch")).toBe(false);
|
||||
expect(isDangerousSql('POST /indexes/movies/documents/fetch\n{"limit":10}', "meilisearch")).toBe(false);
|
||||
expect(isDangerousSql('POST /indexes/movies/search\n{"q":"space"}', "meilisearch")).toBe(false);
|
||||
expect(isDangerousSql('POST /indexes\n{"uid":"movies"}', "meilisearch")).toBe(true);
|
||||
expect(isDangerousSql("PUT /indexes/movies/settings", "meilisearch")).toBe(true);
|
||||
expect(isDangerousSql("DELETE /indexes/movies/documents/001", "meilisearch")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats Elasticsearch PATCH requests as mutating", () => {
|
||||
expect(isDangerousSql('PATCH /products/_settings\n{"index":{"refresh_interval":"5s"}}', "elasticsearch")).toBe(true);
|
||||
expect(isDangerousSql('PATCH /products/_settings\n{"index":{"refresh_interval":"5s"}}', "easysearch")).toBe(true);
|
||||
});
|
||||
|
||||
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {
|
||||
const activeTab = ref<QueryTab | undefined>(queryTab("prod_app"));
|
||||
const activeConnection = ref<ConnectionConfig | undefined>({ ...connection("mysql"), production_databases: ["prod_app"] });
|
||||
|
||||
@@ -19,7 +19,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<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
|
||||
export interface QuickOpenItem {
|
||||
id: string;
|
||||
|
||||
@@ -60,19 +60,28 @@ export function stripSqlComments(sql: string): string {
|
||||
const ELASTICSEARCH_TRANSIENT_DELETE_PATHS = [/^\/_search\/scroll\/?$/i, /^\/_pit\/?$/i, /^\/_async_search\/[^/?]+\/?$/i];
|
||||
const ELASTICSEARCH_DESTRUCTIVE_POST_PATHS = [/(?:^|\/)_(?:delete_by_query|update_by_query|bulk)(?:\/|$)/i, /^\/_reindex(?:\/|$)/i, /^\/_aliases(?:\/|$)/i, /\/_restore(?:\/|$)/i];
|
||||
|
||||
function isDangerousElasticsearchRequest(method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD", path: string): boolean {
|
||||
function isDangerousElasticsearchRequest(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD", path: string): boolean {
|
||||
const pathname = path.split("?", 1)[0].replace(/\/+$/, "") || "/";
|
||||
if (method === "DELETE") return !ELASTICSEARCH_TRANSIENT_DELETE_PATHS.some((pattern) => pattern.test(pathname));
|
||||
if (method === "PUT") return true;
|
||||
if (method === "PUT" || method === "PATCH") return true;
|
||||
return method === "POST" && ELASTICSEARCH_DESTRUCTIVE_POST_PATHS.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
function isDangerousMeilisearchRequest(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD", path: string): boolean {
|
||||
if (method === "GET" || method === "HEAD") return false;
|
||||
if (method === "DELETE" || method === "PUT" || method === "PATCH") return true;
|
||||
const pathname = path.split("?", 1)[0].replace(/\/+$/, "") || "/";
|
||||
return !(pathname === "/multi-search" || /\/search$/i.test(pathname) || /\/facet-search$/i.test(pathname) || /\/similar$/i.test(pathname) || /\/documents\/fetch$/i.test(pathname));
|
||||
}
|
||||
|
||||
export function isDangerousSql(sql: string, databaseType?: DatabaseType): boolean {
|
||||
if (databaseType === "elasticsearch" || databaseType === "easysearch") {
|
||||
if (databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch") {
|
||||
const requests = splitSqlStatementRanges(sql, databaseType)
|
||||
.map((statement) => parseElasticsearchRestRequestTarget(statement.sql))
|
||||
.filter((request): request is NonNullable<typeof request> => request !== null);
|
||||
if (requests.length > 0) return requests.some((request) => isDangerousElasticsearchRequest(request.method, request.path));
|
||||
if (requests.length > 0) {
|
||||
return requests.some((request) => (databaseType === "meilisearch" ? isDangerousMeilisearchRequest(request.method, request.path) : isDangerousElasticsearchRequest(request.method, request.path)));
|
||||
}
|
||||
}
|
||||
const cleaned = stripSqlComments(sql);
|
||||
return cleaned.split(";").some((stmt) => DANGER_RE.test(stmt));
|
||||
@@ -562,6 +571,7 @@ export function useSqlExecution(deps: {
|
||||
|
||||
export function supportsSqlTemplateParameters(connection: Pick<ConnectionConfig, "db_type"> | undefined, sql = ""): boolean {
|
||||
if (!connection) return false;
|
||||
if (connection.db_type === "meilisearch") return false;
|
||||
if (connection.db_type === "elasticsearch" || connection.db_type === "easysearch") return !isElasticsearchRestRequestText(sql);
|
||||
return connection.db_type !== "redis" && connection.db_type !== "mongodb" && connection.db_type !== "victoriametrics";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { documentFilterModeOptionsFor, documentStoreProviderFor } from "@/lib/app/documentStoreProvider";
|
||||
|
||||
describe("Meilisearch document store provider", () => {
|
||||
it("uses a dedicated provider and describes the DBX fetch operation", () => {
|
||||
const provider = documentStoreProviderFor("meilisearch");
|
||||
|
||||
expect(provider.kind).toBe("meilisearch");
|
||||
expect(
|
||||
provider.queryPreview({
|
||||
collection: "movies",
|
||||
filterJson: '{"status":"published"}',
|
||||
sortJson: '{"rating":-1}',
|
||||
skip: 20,
|
||||
limit: 10,
|
||||
}),
|
||||
).toBe('DBX MEILISEARCH FETCH DOCUMENTS\nindex: "movies"\noffset: 20\nlimit: 10\nfilter:\n{\n "status": "published"\n}\nsort:\n{\n "rating": -1\n}');
|
||||
});
|
||||
|
||||
it("does not offer structured contains modes by default", () => {
|
||||
expect(documentFilterModeOptionsFor("meilisearch").map((option) => option.value)).not.toEqual(expect.arrayContaining(["like", "not-like"]));
|
||||
expect(documentFilterModeOptionsFor("elasticsearch").map((option) => option.value)).toEqual(expect.arrayContaining(["like", "not-like"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { serializeDocumentStoreId } from "@/lib/app/documentJsonValues";
|
||||
import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview } from "@/lib/app/documentStoreSave";
|
||||
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
|
||||
describe("Meilisearch document saves", () => {
|
||||
it("preserves the distinction between string and numeric document ids", () => {
|
||||
expect(serializeDocumentStoreId("001", "meilisearch")).toBe('__dbx_meilisearch_string_id__"001"');
|
||||
expect(serializeDocumentStoreId(1, "meilisearch")).toBe("1");
|
||||
expect(serializeDocumentStoreId(parseJsonPreservingLargeNumbers("9007199254740993"), "meilisearch")).toBe("9007199254740993");
|
||||
});
|
||||
|
||||
it("describes DBX write semantics without inventing a native primary-key field", () => {
|
||||
const preview = formatMeilisearchDocumentOperationPreview({
|
||||
action: "update",
|
||||
index: "movies",
|
||||
id: "001",
|
||||
document: { title: "Arrival", rating: 9 },
|
||||
});
|
||||
|
||||
expect(preview).toContain("DBX MEILISEARCH UPDATE DOCUMENT");
|
||||
expect(preview).toContain('index: "movies"');
|
||||
expect(preview).toContain('id: "001"');
|
||||
expect(preview).toContain('"title": "Arrival"');
|
||||
expect(preview).not.toContain("PUT /indexes/");
|
||||
});
|
||||
|
||||
it("writes the new identity before deleting the old document", async () => {
|
||||
const calls: string[] = [];
|
||||
const update = vi.fn(async (id: string) => {
|
||||
calls.push(`update:${id}`);
|
||||
return 1;
|
||||
});
|
||||
const remove = vi.fn(async (id: string) => {
|
||||
calls.push(`delete:${id}`);
|
||||
return 1;
|
||||
});
|
||||
|
||||
await applyDocumentStoreIdentityPlan({
|
||||
kind: "meilisearch",
|
||||
plan: {
|
||||
action: "rekey",
|
||||
writeId: serializeDocumentStoreId("002", "meilisearch"),
|
||||
deleteId: serializeDocumentStoreId("001", "meilisearch"),
|
||||
},
|
||||
document: { _id: "002", title: "Arrival" },
|
||||
apis: {
|
||||
insert: vi.fn(async () => ""),
|
||||
update,
|
||||
delete: remove,
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['update:__dbx_meilisearch_string_id__"002"', 'delete:__dbx_meilisearch_string_id__"001"']);
|
||||
expect(update).toHaveBeenCalledWith('__dbx_meilisearch_string_id__"002"', '{"title":"Arrival"}', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { connectionProfileForScheme, parseConnectionUrl } from "@/lib/connection/connectionUrl";
|
||||
|
||||
describe("Meilisearch connection URLs", () => {
|
||||
it("parses the dedicated scheme with the default port and API key", () => {
|
||||
expect(parseConnectionUrl("meilisearch://:secret@search.example.com")).toMatchObject({
|
||||
dbType: "meilisearch",
|
||||
driverProfile: "meilisearch",
|
||||
driverLabel: "Meilisearch",
|
||||
host: "search.example.com",
|
||||
port: 7700,
|
||||
username: "",
|
||||
password: "secret",
|
||||
ssl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Meilisearch selected for HTTPS URLs", () => {
|
||||
expect(parseConnectionUrl("https://search.example.com:8443?insecure=true", "meilisearch")).toMatchObject({
|
||||
dbType: "meilisearch",
|
||||
driverProfile: "meilisearch",
|
||||
port: 8443,
|
||||
urlParams: "insecure=true",
|
||||
ssl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the Meilisearch scheme profile", () => {
|
||||
expect(connectionProfileForScheme("meilisearch")).toEqual({
|
||||
type: "meilisearch",
|
||||
profile: "meilisearch",
|
||||
label: "Meilisearch",
|
||||
defaultPort: 7700,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ describe("dataGridColumnNameCopy", () => {
|
||||
expect(supportsColumnNameQuoting("redis")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("elasticsearch")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("easysearch")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("meilisearch")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("qdrant")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("jdbc")).toBe(false);
|
||||
expect(supportsColumnNameQuoting("iotdb")).toBe(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ describe("dataGridPreviewLabelKey", () => {
|
||||
expect(dataGridPreviewLabelKey("mongodb")).toBe("toolbar.previewQuery");
|
||||
expect(dataGridPreviewLabelKey("elasticsearch")).toBe("toolbar.previewQuery");
|
||||
expect(dataGridPreviewLabelKey("easysearch")).toBe("toolbar.previewQuery");
|
||||
expect(dataGridPreviewLabelKey("meilisearch")).toBe("toolbar.previewQuery");
|
||||
});
|
||||
|
||||
it("keeps the SQL wording for SQL databases", () => {
|
||||
|
||||
@@ -4,7 +4,9 @@ import { databaseRuntimeMode, usesAgentCursorForQuery } from "@/lib/database/dat
|
||||
describe("databaseDriverManifest", () => {
|
||||
it("uses agent cursor only for agent or external runtimes", () => {
|
||||
expect(databaseRuntimeMode("gaussdb")).toBe("native");
|
||||
expect(databaseRuntimeMode("meilisearch")).toBe("native");
|
||||
expect(usesAgentCursorForQuery("gaussdb")).toBe(false);
|
||||
expect(usesAgentCursorForQuery("meilisearch")).toBe(false);
|
||||
expect(usesAgentCursorForQuery("mysql")).toBe(false);
|
||||
expect(usesAgentCursorForQuery("jdbc")).toBe(true);
|
||||
expect(usesAgentCursorForQuery("prestosql")).toBe(true);
|
||||
|
||||
@@ -107,6 +107,7 @@ describe("supportsSqlInListPaste", () => {
|
||||
expect(supportsSqlInListPaste("mongodb")).toBe(false);
|
||||
expect(supportsSqlInListPaste("elasticsearch")).toBe(false);
|
||||
expect(supportsSqlInListPaste("easysearch")).toBe(false);
|
||||
expect(supportsSqlInListPaste("meilisearch")).toBe(false);
|
||||
expect(supportsSqlInListPaste("qdrant")).toBe(false);
|
||||
expect(supportsSqlInListPaste("milvus")).toBe(false);
|
||||
expect(supportsSqlInListPaste("weaviate")).toBe(false);
|
||||
|
||||
@@ -474,7 +474,7 @@ POST /orders/_search
|
||||
|
||||
HEAD /orders`;
|
||||
|
||||
for (const databaseType of ["elasticsearch", "easysearch"] as const) {
|
||||
for (const databaseType of ["elasticsearch", "easysearch", "meilisearch"] 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);
|
||||
}
|
||||
@@ -1447,6 +1447,7 @@ describe("supportsExecutionTargetPicker", () => {
|
||||
expect(supportsExecutionTargetPicker("mongodb")).toBe(false);
|
||||
expect(supportsExecutionTargetPicker("elasticsearch")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("easysearch")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("meilisearch")).toBe(true);
|
||||
expect(supportsExecutionTargetPicker("qdrant")).toBe(false);
|
||||
expect(supportsExecutionTargetPicker("milvus")).toBe(false);
|
||||
expect(supportsExecutionTargetPicker("weaviate")).toBe(false);
|
||||
|
||||
@@ -29,17 +29,20 @@ export function parseDocumentStoreInputValue(raw: MongoInputValue, kind: Documen
|
||||
}
|
||||
|
||||
export function stringifyDocumentStoreValue(value: unknown, kind: DocumentStoreKind, indent?: number): string {
|
||||
return kind === "elasticsearch" ? stringifyJsonPreservingLargeNumbers(value, indent) : JSON.stringify(value, null, indent ?? undefined);
|
||||
return kind === "mongodb" ? JSON.stringify(value, null, indent ?? undefined) : stringifyJsonPreservingLargeNumbers(value, indent);
|
||||
}
|
||||
|
||||
export function documentStoreValueForGrid(value: unknown, kind: DocumentStoreKind): MongoInputValue {
|
||||
if (kind === "elasticsearch" && isLosslessJsonNumber(value)) return value.raw;
|
||||
if (kind !== "mongodb" && isLosslessJsonNumber(value)) return value.raw;
|
||||
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||
return stringifyDocumentStoreValue(value, kind);
|
||||
}
|
||||
|
||||
export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind): string {
|
||||
return kind === "elasticsearch" ? String(value) : serializeMongoDocumentId(value);
|
||||
if (kind !== "mongodb" && isLosslessJsonNumber(value)) return value.raw;
|
||||
if (kind === "elasticsearch") return String(value);
|
||||
if (kind === "meilisearch") return typeof value === "string" ? `__dbx_meilisearch_string_id__${JSON.stringify(value)}` : String(value);
|
||||
return serializeMongoDocumentId(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ComposerTranslation } from "vue-i18n";
|
||||
import { normalizeJsonArgument } from "@dbx-app/mongo-shell";
|
||||
import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
|
||||
import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type DatabaseType } from "@/types/database";
|
||||
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
|
||||
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
|
||||
|
||||
export type DocumentStoreKind = "mongodb" | "elasticsearch";
|
||||
export type DocumentStoreKind = "mongodb" | "elasticsearch" | "meilisearch";
|
||||
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-null" | "is-not-null";
|
||||
export type DocumentFilterValueType = "auto" | "string" | "number" | "boolean" | "object-id" | "date" | "int32" | "int64" | "decimal128" | "json";
|
||||
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
|
||||
@@ -64,6 +64,10 @@ export const documentFilterModeOptions: Array<{ value: DocumentFilterMode; label
|
||||
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
|
||||
];
|
||||
|
||||
export function documentFilterModeOptionsFor(kind: DocumentStoreKind): Array<{ value: DocumentFilterMode; labelKey: string }> {
|
||||
return kind === "meilisearch" ? documentFilterModeOptions.filter((option) => option.value !== "like" && option.value !== "not-like") : documentFilterModeOptions;
|
||||
}
|
||||
|
||||
export const documentFilterValueTypeOptions: Array<{ value: DocumentFilterValueType; labelKey: string }> = [
|
||||
{ value: "auto", labelKey: "grid.filterBuilderValueTypeAuto" },
|
||||
{ value: "string", labelKey: "grid.filterBuilderValueTypeString" },
|
||||
@@ -130,8 +134,36 @@ const elasticsearchDocumentProvider: DocumentStoreProvider = {
|
||||
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
|
||||
};
|
||||
|
||||
const meilisearchDocumentProvider: DocumentStoreProvider = {
|
||||
kind: "meilisearch",
|
||||
filterInputLabel: "filter",
|
||||
sortInputLabel: "sort",
|
||||
documentsLabel: ({ total }) => `${total} Documents`,
|
||||
queryPreview: ({ collection, filterJson, sortJson, skip, limit }) => {
|
||||
const lines = ["DBX MEILISEARCH FETCH DOCUMENTS", `index: ${JSON.stringify(collection)}`, `offset: ${skip}`, `limit: ${limit}`];
|
||||
const filter = documentStorePreviewJson(filterJson);
|
||||
if (filter) lines.push("filter:", filter);
|
||||
const sort = documentStorePreviewJson(sortJson);
|
||||
if (sort) lines.push("sort:", sort);
|
||||
return lines.join("\n");
|
||||
},
|
||||
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
|
||||
};
|
||||
|
||||
function documentStorePreviewJson(value?: string): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === "{}") return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function documentStoreProviderFor(databaseType?: DatabaseType): DocumentStoreProvider {
|
||||
return isElasticsearchCompatibleDatabaseType(databaseType) ? elasticsearchDocumentProvider : mongoDocumentProvider;
|
||||
if (isElasticsearchCompatibleDatabaseType(databaseType)) return elasticsearchDocumentProvider;
|
||||
if (isMeilisearchDatabaseType(databaseType)) return meilisearchDocumentProvider;
|
||||
return mongoDocumentProvider;
|
||||
}
|
||||
|
||||
export function defaultDocumentFilterRule(id: string, fieldName = ""): DocumentFilterRule {
|
||||
@@ -639,6 +671,7 @@ type DocumentQueryInputNormalizer = (input: string) => string | null;
|
||||
const documentQueryInputNormalizers: Record<DocumentStoreKind, DocumentQueryInputNormalizer> = {
|
||||
mongodb: normalizeJsonArgument,
|
||||
elasticsearch: quoteUnquotedObjectKeys,
|
||||
meilisearch: quoteUnquotedObjectKeys,
|
||||
};
|
||||
|
||||
function normalizeDocumentQueryObjectInput(input: string, kind?: DocumentStoreKind): string {
|
||||
|
||||
@@ -7,6 +7,13 @@ export type DocumentStoreWriteApis = {
|
||||
delete: (id: string, routing?: string) => Promise<number>;
|
||||
};
|
||||
|
||||
export function formatMeilisearchDocumentOperationPreview(options: { action: "insert" | "upsert" | "update" | "delete"; index: string; id?: unknown; document?: Record<string, unknown> }): string {
|
||||
const lines = [`DBX MEILISEARCH ${options.action.toUpperCase()} DOCUMENT`, `index: ${JSON.stringify(options.index)}`];
|
||||
if (options.id !== undefined) lines.push(`id: ${stringifyDocumentStoreValue(options.id, "meilisearch")}`);
|
||||
if (options.document) lines.push("document:", stringifyDocumentStoreValue(options.document, "meilisearch", 2));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a document body under a known identity.
|
||||
* - `put`: Elasticsearch index-by-id / Mongo update-by-id (identity via path, not body).
|
||||
@@ -48,8 +55,8 @@ export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentSt
|
||||
return;
|
||||
}
|
||||
|
||||
// Rekey write: ES uses put under the new id/routing; Mongo inserts a new document then deletes the old id.
|
||||
if (kind === "elasticsearch") {
|
||||
// Path-identity stores write under the new id first; Mongo inserts a new document.
|
||||
if (kind !== "mongodb") {
|
||||
await writeDocumentStoreDocument({
|
||||
kind,
|
||||
op: "put",
|
||||
@@ -71,10 +78,10 @@ export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentSt
|
||||
await apis.delete(plan.deleteId, plan.deleteRouting);
|
||||
}
|
||||
|
||||
/** Insert a new document (optional explicit ES id uses put). */
|
||||
/** Insert a new document (optional explicit path identity uses put). */
|
||||
export async function insertDocumentStoreDocument(options: { kind: DocumentStoreKind; document: Record<string, unknown>; explicitId?: string | null; routing?: string; apis: Pick<DocumentStoreWriteApis, "insert" | "update"> }): Promise<void> {
|
||||
const { kind, document, explicitId, routing, apis } = options;
|
||||
if (kind === "elasticsearch" && explicitId) {
|
||||
if (kind !== "mongodb" && explicitId) {
|
||||
await writeDocumentStoreDocument({
|
||||
kind,
|
||||
op: "put",
|
||||
|
||||
@@ -699,6 +699,7 @@ export const documentUpdateDocument = forward("documentUpdateDocument");
|
||||
export const mongoUpdateDocument = forward("mongoUpdateDocument");
|
||||
export const mongoUpdateDocuments = forward("mongoUpdateDocuments");
|
||||
export const documentDeleteDocument = forward("documentDeleteDocument");
|
||||
export const documentSaveMeilisearchBatch = forward("documentSaveMeilisearchBatch");
|
||||
export const mongoDeleteDocument = forward("mongoDeleteDocument");
|
||||
export const mongoDeleteDocuments = forward("mongoDeleteDocuments");
|
||||
export const mongoFindOneAndUpdate = forward("mongoFindOneAndUpdate");
|
||||
|
||||
@@ -3668,6 +3668,16 @@ export async function documentDeleteDocument(connectionId: string, database: str
|
||||
});
|
||||
}
|
||||
|
||||
export async function documentSaveMeilisearchBatch(connectionId: string, collection: string, updates: Array<{ id: string; docJson: string }>, deleteIds: string[], inserts: string[]): Promise<number> {
|
||||
return post("/api/document-store/save-meilisearch-batch", {
|
||||
connectionId,
|
||||
collection,
|
||||
updates,
|
||||
deleteIds,
|
||||
inserts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
|
||||
return post("/api/mongo/delete-documents", {
|
||||
connectionId,
|
||||
|
||||
@@ -3615,6 +3615,16 @@ export async function documentDeleteDocument(connectionId: string, database: str
|
||||
});
|
||||
}
|
||||
|
||||
export async function documentSaveMeilisearchBatch(connectionId: string, collection: string, updates: Array<{ id: string; docJson: string }>, deleteIds: string[], inserts: string[]): Promise<number> {
|
||||
return invoke("document_save_meilisearch_batch", {
|
||||
connectionId,
|
||||
collection,
|
||||
updates,
|
||||
deleteIds,
|
||||
inserts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function mongoDeleteDocuments(connectionId: string, database: string, collection: string, filterJson: string, many: boolean): Promise<{ affected_rows: number }> {
|
||||
const affectedRows = await invoke<number>("mongo_delete_documents", {
|
||||
connectionId,
|
||||
|
||||
@@ -129,6 +129,7 @@ export function connectionDisplayUrlScheme(connection: Pick<ConnectionConfig, "d
|
||||
return "mssql";
|
||||
case "elasticsearch":
|
||||
case "easysearch":
|
||||
case "meilisearch":
|
||||
case "qdrant":
|
||||
case "milvus":
|
||||
case "weaviate":
|
||||
@@ -207,6 +208,7 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
|
||||
|
||||
case "elasticsearch":
|
||||
case "easysearch":
|
||||
case "meilisearch":
|
||||
case "qdrant":
|
||||
case "milvus":
|
||||
case "weaviate":
|
||||
|
||||
@@ -53,6 +53,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
|
||||
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 },
|
||||
meilisearch: { type: "meilisearch", profile: "meilisearch", label: "Meilisearch", defaultPort: 7700 },
|
||||
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 },
|
||||
@@ -82,6 +83,7 @@ const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
|
||||
clickhouse: SCHEME_PROFILES.clickhouse,
|
||||
elasticsearch: SCHEME_PROFILES.elasticsearch,
|
||||
easysearch: SCHEME_PROFILES.easysearch,
|
||||
meilisearch: SCHEME_PROFILES.meilisearch,
|
||||
qdrant: SCHEME_PROFILES.qdrant,
|
||||
milvus: SCHEME_PROFILES.milvus,
|
||||
weaviate: SCHEME_PROFILES.weaviate,
|
||||
|
||||
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
|
||||
|
||||
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
|
||||
// non-database services expose their own root objects rather than database namespaces.
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
|
||||
type VisibleDatabaseConnectionFields = Pick<
|
||||
ConnectionConfig,
|
||||
|
||||
@@ -25,7 +25,7 @@ export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCo
|
||||
}
|
||||
|
||||
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
|
||||
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
|
||||
const UNQUOTABLE_DATABASE_TYPES = new Set<DatabaseType>(["jdbc", "iotdb"]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["mongodb", "elasticsearch", "easysearch"]);
|
||||
const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["mongodb", "elasticsearch", "easysearch", "meilisearch"]);
|
||||
|
||||
export function dataGridPreviewLabelKey(databaseType?: DatabaseType): "toolbar.previewQuery" | "toolbar.previewSql" {
|
||||
return databaseType && NON_SQL_PREVIEW_DATABASE_TYPES.has(databaseType) ? "toolbar.previewQuery" : "toolbar.previewSql";
|
||||
|
||||
@@ -30,6 +30,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
|
||||
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" },
|
||||
meilisearch: { 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" },
|
||||
|
||||
@@ -30,6 +30,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
|
||||
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" },
|
||||
meilisearch: { 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" },
|
||||
|
||||
@@ -109,6 +109,7 @@ const SQL_EXECUTION_TARGET_REGISTRY: Partial<Record<DatabaseType, SqlExecutionTa
|
||||
// safely apply a selected index/collection/database to the actual request.
|
||||
elasticsearch: { scope: "connection" },
|
||||
easysearch: { scope: "connection" },
|
||||
meilisearch: { scope: "connection" },
|
||||
qdrant: { scope: "connection" },
|
||||
milvus: { scope: "connection" },
|
||||
weaviate: { scope: "connection" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export function sortSidebarTreeChildrenForParent(parent: Pick<TreeNode, "type">,
|
||||
const regularChildren = normalized.filter((child) => child.type !== "user-admin" && child.type !== "dameng-users" && child.type !== "dameng-roles" && child.type !== "saved-sql-root");
|
||||
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlNodes, ...children, ...userAdminNodes];
|
||||
|
||||
if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
|
||||
if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
|
||||
return withConnectionUtilityOrder(sortByLabel(regularChildren));
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, opt
|
||||
options.databaseType === "mongodb" ||
|
||||
options.databaseType === "elasticsearch" ||
|
||||
options.databaseType === "easysearch" ||
|
||||
options.databaseType === "meilisearch" ||
|
||||
options.databaseType === "qdrant" ||
|
||||
options.databaseType === "milvus" ||
|
||||
options.databaseType === "weaviate" ||
|
||||
@@ -304,6 +305,7 @@ export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number,
|
||||
options.databaseType === "mongodb" ||
|
||||
options.databaseType === "elasticsearch" ||
|
||||
options.databaseType === "easysearch" ||
|
||||
options.databaseType === "meilisearch" ||
|
||||
options.databaseType === "qdrant" ||
|
||||
options.databaseType === "milvus" ||
|
||||
options.databaseType === "weaviate" ||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { cursorBelongsToTrailingStatementDelimiter } from "@/lib/sql/statementDelimiter";
|
||||
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
|
||||
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||||
import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
|
||||
import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type DatabaseType } from "@/types/database";
|
||||
|
||||
/**
|
||||
* A contiguous range of SQL text expressed as document offsets plus the
|
||||
@@ -14,18 +14,22 @@ export interface SqlTextRange {
|
||||
sql: string;
|
||||
}
|
||||
|
||||
const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
|
||||
const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+\S+/i;
|
||||
|
||||
function isHttpJsonRestDatabaseType(databaseType?: DatabaseType): boolean {
|
||||
return isElasticsearchCompatibleDatabaseType(databaseType) || isMeilisearchDatabaseType(databaseType);
|
||||
}
|
||||
|
||||
export function elasticsearchRestRequestRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
|
||||
if (!isElasticsearchCompatibleDatabaseType(databaseType)) return [];
|
||||
if (!isHttpJsonRestDatabaseType(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<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
|
||||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
|
||||
|
||||
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
|
||||
return !!databaseType && (databaseType === "redis" || isElasticsearchCompatibleDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
|
||||
return !!databaseType && (databaseType === "redis" || isHttpJsonRestDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
|
||||
}
|
||||
|
||||
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||||
@@ -58,11 +62,11 @@ interface ElasticsearchRequestLineCandidate {
|
||||
}
|
||||
|
||||
export interface ElasticsearchRestRequestTarget {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
||||
path: string;
|
||||
}
|
||||
|
||||
const ELASTICSEARCH_REST_REQUEST_LINE = /^\s*(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
|
||||
const ELASTICSEARCH_REST_REQUEST_LINE = /^\s*(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+\S+/i;
|
||||
|
||||
function leadingElasticsearchPreambleEnd(value: string): number {
|
||||
let offset = 0;
|
||||
@@ -145,7 +149,7 @@ function elasticsearchRequestLineCandidates(lines: ElasticsearchRequestLine[]):
|
||||
|
||||
export function parseElasticsearchRestRequestTarget(value: string): ElasticsearchRestRequestTarget | null {
|
||||
const requestLine = stripLeadingElasticsearchComments(value).split("\n", 1)[0]?.trim() ?? "";
|
||||
const match = requestLine.match(/^(GET|POST|PUT|DELETE|HEAD)\s+(\S+)/i);
|
||||
const match = requestLine.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(\S+)/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
method: match[1].toUpperCase() as ElasticsearchRestRequestTarget["method"],
|
||||
@@ -284,7 +288,7 @@ const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]);
|
||||
* whitespace are excluded so editor highlights stay tight).
|
||||
*/
|
||||
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] {
|
||||
if (isElasticsearchCompatibleDatabaseType(databaseType)) {
|
||||
if (isHttpJsonRestDatabaseType(databaseType)) {
|
||||
const requests = splitElasticsearchRestRequestRanges(sql);
|
||||
if (requests) return requests;
|
||||
}
|
||||
@@ -594,7 +598,7 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT
|
||||
// Cursor in indentation or inter-statement whitespace immediately before
|
||||
// the statement should still target that statement, while the returned
|
||||
// execution range remains tight around the SQL text itself.
|
||||
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (isElasticsearchCompatibleDatabaseType(databaseType) && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
|
||||
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (isHttpJsonRestDatabaseType(databaseType) && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
|
||||
const previous = statements[index - 1];
|
||||
if (previous && isCursorInTrailingDelimiterGap(sql, previous.to, pos)) {
|
||||
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType, parameterOptions);
|
||||
|
||||
@@ -43,6 +43,12 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
|
||||
triggers: false,
|
||||
ddl: false,
|
||||
},
|
||||
meilisearch: {
|
||||
indexes: false,
|
||||
foreignKeys: false,
|
||||
triggers: false,
|
||||
ddl: false,
|
||||
},
|
||||
hbase: {
|
||||
indexes: false,
|
||||
foreignKeys: false,
|
||||
|
||||
@@ -1004,6 +1004,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
"mongodb-legacy": MONGO_LEGACY_DRIVER_LABEL,
|
||||
elasticsearch: "Elasticsearch",
|
||||
easysearch: "Easysearch",
|
||||
meilisearch: "Meilisearch",
|
||||
qdrant: "Qdrant",
|
||||
milvus: "Milvus",
|
||||
weaviate: "Weaviate",
|
||||
@@ -2813,7 +2814,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadConsulRoot(connectionId);
|
||||
} else if (config.db_type === "mongodb") {
|
||||
await loadMongoDatabases(connectionId);
|
||||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
|
||||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
|
||||
// Reload: list indices.
|
||||
await loadElasticsearchIndices(connectionId);
|
||||
} else if (config.db_type === "milvus") {
|
||||
@@ -3385,7 +3386,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", "consul", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||||
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "elasticsearch", "easysearch", "meilisearch", "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) };
|
||||
@@ -5420,7 +5421,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadConsulRoot(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
|
||||
await loadElasticsearchIndices(node.connectionId);
|
||||
} else if (config?.db_type === "milvus") {
|
||||
await loadMilvusDatabases(node.connectionId);
|
||||
|
||||
@@ -16,6 +16,7 @@ export type DatabaseType =
|
||||
| "oracle"
|
||||
| "elasticsearch"
|
||||
| "easysearch"
|
||||
| "meilisearch"
|
||||
| "hbase"
|
||||
| "qdrant"
|
||||
| "milvus"
|
||||
@@ -78,6 +79,10 @@ export function isElasticsearchCompatibleDatabaseType(dbType?: DatabaseType): bo
|
||||
return dbType === "elasticsearch" || dbType === "easysearch";
|
||||
}
|
||||
|
||||
export function isMeilisearchDatabaseType(dbType?: DatabaseType): boolean {
|
||||
return dbType === "meilisearch";
|
||||
}
|
||||
|
||||
export interface SqlSnippet {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ const BRIDGE_REQUIRED_TYPES: &[&str] = &[
|
||||
"oracle",
|
||||
"elasticsearch",
|
||||
"easysearch",
|
||||
"meilisearch",
|
||||
"qdrant",
|
||||
"milvus",
|
||||
"weaviate",
|
||||
|
||||
@@ -384,6 +384,35 @@
|
||||
"driverManagement": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"dbType": "meilisearch",
|
||||
"label": "Meilisearch",
|
||||
"runtimeMode": "native",
|
||||
"mcpMode": "bridge",
|
||||
"singleConnectionPool": false,
|
||||
"metadataConnectionScoped": false,
|
||||
"skipTcpProbe": false,
|
||||
"defaultPort": 7700,
|
||||
"supportLevel": "browse",
|
||||
"capabilities": {
|
||||
"queryExecution": true,
|
||||
"metadataBrowse": true,
|
||||
"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",
|
||||
|
||||
@@ -103,6 +103,7 @@ pub enum PoolKind {
|
||||
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
|
||||
Elasticsearch(db::elasticsearch_driver::EsClient),
|
||||
Easysearch(db::easysearch_driver::EasysearchClient),
|
||||
Meilisearch(db::meilisearch_driver::MeilisearchClient),
|
||||
HBase(db::hbase_driver::HBaseClient),
|
||||
VectorDb(db::vector_driver::VectorClient),
|
||||
InfluxDb(db::influxdb_driver::InfluxdbClient),
|
||||
@@ -1989,6 +1990,17 @@ impl AppState {
|
||||
db::easysearch_driver::test_connection(&mut client, connect_timeout).await?;
|
||||
PoolKind::Easysearch(client)
|
||||
}
|
||||
DatabaseType::Meilisearch => {
|
||||
let client = db::meilisearch_driver::MeilisearchClient::new(
|
||||
&url,
|
||||
Some(&db_config.password),
|
||||
db_config.ssl,
|
||||
db_config.url_params.as_deref(),
|
||||
connect_timeout,
|
||||
)?;
|
||||
db::meilisearch_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Meilisearch(client)
|
||||
}
|
||||
DatabaseType::Hbase => {
|
||||
let client = db::hbase_driver::HBaseClient::new(
|
||||
&url,
|
||||
@@ -3049,6 +3061,18 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
let timeout = crate::db::connection_timeout();
|
||||
match db::meilisearch_driver::test_connection(&client, timeout).await {
|
||||
Ok(()) => false,
|
||||
Err(err) => {
|
||||
log::warn!("Meilisearch connection pool '{pool_key}' is stale: {err}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::HBase(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
@@ -3979,6 +4003,16 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
match db::meilisearch_driver::test_connection(&client, timeout).await {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Meilisearch connection pool '{key}' is unhealthy: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::HBase(client) => match db::hbase_driver::test_connection(client, timeout).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
@@ -4717,6 +4751,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
|
||||
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
|
||||
PoolKind::Elasticsearch(client) => PoolKind::Elasticsearch(client.clone()),
|
||||
PoolKind::Easysearch(client) => PoolKind::Easysearch(client.clone()),
|
||||
PoolKind::Meilisearch(client) => PoolKind::Meilisearch(client.clone()),
|
||||
PoolKind::HBase(client) => PoolKind::HBase(client.clone()),
|
||||
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
|
||||
PoolKind::InfluxDb(client) => PoolKind::InfluxDb(client.clone()),
|
||||
@@ -4768,6 +4803,9 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
|
||||
PoolKind::Easysearch(client) => {
|
||||
drop(client);
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
drop(client);
|
||||
}
|
||||
PoolKind::HBase(client) => {
|
||||
drop(client);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use reqwest::{Client as HttpClient, Method, RequestBuilder, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{http_client_builder, ColumnInfo};
|
||||
use crate::db::document_result::DocumentQueryResult;
|
||||
use crate::types::QueryResult;
|
||||
|
||||
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'#')
|
||||
.add(b'%')
|
||||
.add(b'/')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'?')
|
||||
.add(b'[')
|
||||
.add(b'\\')
|
||||
.add(b']')
|
||||
.add(b'^')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
const INDEX_PAGE_SIZE: u64 = 1_000;
|
||||
const FIELD_SAMPLE_SIZE: u64 = 100;
|
||||
const TASK_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const TASK_WAIT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const REST_RESPONSE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MeilisearchClient {
|
||||
http: HttpClient,
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl MeilisearchClient {
|
||||
pub fn new(
|
||||
url: &str,
|
||||
api_key: Option<&str>,
|
||||
tls_enabled: bool,
|
||||
url_params: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> Result<Self, String> {
|
||||
let mut builder = http_client_builder(timeout);
|
||||
if meilisearch_accept_invalid_certs(tls_enabled, url_params) {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
let http = builder.build().map_err(|error| format!("Meilisearch HTTP client error: {error}"))?;
|
||||
let api_key = api_key.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||
Ok(Self { http, base_url: url.trim_end_matches('/').to_string(), api_key })
|
||||
}
|
||||
|
||||
fn request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||
let request = self.http.request(method, format!("{}{}", self.base_url, path));
|
||||
match self.api_key.as_deref() {
|
||||
Some(api_key) => request.bearer_auth(api_key),
|
||||
None => request,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> RequestBuilder {
|
||||
self.request(Method::GET, path)
|
||||
}
|
||||
|
||||
fn post(&self, path: &str) -> RequestBuilder {
|
||||
self.request(Method::POST, path)
|
||||
}
|
||||
|
||||
fn delete(&self, path: &str) -> RequestBuilder {
|
||||
self.request(Method::DELETE, path)
|
||||
}
|
||||
}
|
||||
|
||||
fn meilisearch_accept_invalid_certs(tls_enabled: bool, url_params: Option<&str>) -> bool {
|
||||
tls_enabled
|
||||
&& url_params.is_some_and(|params| {
|
||||
params.trim_start_matches('?').split('&').any(|pair| {
|
||||
let (key, value) = pair.split_once('=').unwrap_or((pair, "true"));
|
||||
matches!(key.trim().to_ascii_lowercase().as_str(), "insecure" | "tls_insecure" | "accept_invalid_certs")
|
||||
&& matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_path_segment(value: &str) -> String {
|
||||
utf8_percent_encode(value, PATH_SEGMENT_ENCODE_SET).to_string()
|
||||
}
|
||||
|
||||
async fn response_json(response: reqwest::Response, context: &str) -> Result<Value, String> {
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|error| format!("Meilisearch response error: {error}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(meilisearch_error(context, status, &body));
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|error| format!("Meilisearch parse error: {error}"))
|
||||
}
|
||||
|
||||
fn meilisearch_error(context: &str, status: StatusCode, body: &str) -> String {
|
||||
let detail = serde_json::from_str::<Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| value.get("message").and_then(Value::as_str).map(str::to_string))
|
||||
.unwrap_or_else(|| body.trim().to_string());
|
||||
if detail.is_empty() {
|
||||
format!("Meilisearch {context} failed with HTTP {}", status.as_u16())
|
||||
} else {
|
||||
format!("Meilisearch {context} failed with HTTP {}: {detail}", status.as_u16())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_connection(client: &MeilisearchClient, _timeout: Duration) -> Result<(), String> {
|
||||
let health = client.get("/health").send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
response_json(health, "health check").await?;
|
||||
|
||||
let indexes = client
|
||||
.get("/indexes?offset=0&limit=1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
response_json(indexes, "index access check").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct IndexInfoResponse {
|
||||
uid: String,
|
||||
primary_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IndexListResponse {
|
||||
results: Vec<IndexInfoResponse>,
|
||||
total: u64,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
}
|
||||
|
||||
async fn index_info(client: &MeilisearchClient, index: &str) -> Result<IndexInfoResponse, String> {
|
||||
let response = client
|
||||
.get(&format!("/indexes/{}", encode_path_segment(index)))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "index lookup").await?;
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch index parse error: {error}"))
|
||||
}
|
||||
|
||||
pub async fn list_indexes(client: &MeilisearchClient) -> Result<Vec<String>, String> {
|
||||
let mut offset = 0;
|
||||
let mut names = Vec::new();
|
||||
loop {
|
||||
let response = client
|
||||
.get(&format!("/indexes?offset={offset}&limit={INDEX_PAGE_SIZE}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "index listing").await?;
|
||||
let page: IndexListResponse =
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch index list parse error: {error}"))?;
|
||||
names.extend(page.results.into_iter().map(|index| index.uid));
|
||||
let next_offset = page.offset.saturating_add(page.limit);
|
||||
if next_offset >= page.total || page.limit == 0 {
|
||||
break;
|
||||
}
|
||||
offset = next_offset;
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DocumentsResponse {
|
||||
results: Vec<Value>,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
async fn fetch_documents_value(
|
||||
client: &MeilisearchClient,
|
||||
index: &str,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
filter: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
) -> Result<(DocumentsResponse, Option<String>), String> {
|
||||
let index_info = index_info(client, index).await?;
|
||||
let mut body = Map::new();
|
||||
body.insert("offset".to_string(), Value::Number(offset.into()));
|
||||
body.insert("limit".to_string(), Value::Number(limit.into()));
|
||||
if let Some(filter) = meilisearch_filter_from_request(filter, index_info.primary_key.as_deref())? {
|
||||
body.insert("filter".to_string(), Value::String(filter));
|
||||
}
|
||||
if let Some(sort) = meilisearch_sort_from_request(sort, index_info.primary_key.as_deref())? {
|
||||
body.insert("sort".to_string(), Value::Array(sort.into_iter().map(Value::String).collect()));
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(&format!("/indexes/{}/documents/fetch", encode_path_segment(&index_info.uid)))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let value = response_json(response, "document fetch").await?;
|
||||
let documents =
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch document parse error: {error}"))?;
|
||||
Ok((documents, index_info.primary_key))
|
||||
}
|
||||
|
||||
pub async fn find_documents(
|
||||
client: &MeilisearchClient,
|
||||
index: &str,
|
||||
skip: u64,
|
||||
limit: i64,
|
||||
filter: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
) -> Result<DocumentQueryResult, String> {
|
||||
let limit = u64::try_from(limit.max(0)).unwrap_or(0);
|
||||
let (result, primary_key) = fetch_documents_value(client, index, skip, limit, filter, sort).await?;
|
||||
let documents = result
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|document| inject_document_identity(document, primary_key.as_deref()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(DocumentQueryResult {
|
||||
documents,
|
||||
raw_documents: None,
|
||||
extended_documents: None,
|
||||
total: result.total,
|
||||
total_is_exact: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_columns(client: &MeilisearchClient, index: &str) -> Result<Vec<ColumnInfo>, String> {
|
||||
let (result, primary_key) = fetch_documents_value(client, index, 0, FIELD_SAMPLE_SIZE, None, None).await?;
|
||||
let mut fields = BTreeMap::<String, FieldType>::new();
|
||||
for document in result.results {
|
||||
collect_field_types("", &document, &mut fields);
|
||||
}
|
||||
if let Some(primary_key) = primary_key.as_deref() {
|
||||
fields.entry(primary_key.to_string()).or_insert(FieldType::Unknown);
|
||||
}
|
||||
Ok(fields
|
||||
.into_iter()
|
||||
.map(|(name, field_type)| ColumnInfo {
|
||||
is_primary_key: primary_key.as_deref() == Some(name.as_str()),
|
||||
name,
|
||||
data_type: field_type.label().to_string(),
|
||||
is_nullable: true,
|
||||
column_default: None,
|
||||
is_unique: false,
|
||||
extra: None,
|
||||
comment: None,
|
||||
numeric_precision: None,
|
||||
numeric_scale: None,
|
||||
character_maximum_length: None,
|
||||
enum_values: None,
|
||||
character_set: None,
|
||||
collation: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FieldType {
|
||||
Unknown,
|
||||
Null,
|
||||
Boolean,
|
||||
Number,
|
||||
String,
|
||||
Object,
|
||||
Array,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
impl FieldType {
|
||||
fn from_value(value: &Value) -> Self {
|
||||
match value {
|
||||
Value::Null => Self::Null,
|
||||
Value::Bool(_) => Self::Boolean,
|
||||
Value::Number(_) => Self::Number,
|
||||
Value::String(_) => Self::String,
|
||||
Value::Array(_) => Self::Array,
|
||||
Value::Object(_) => Self::Object,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(self, other: Self) -> Self {
|
||||
match (self, other) {
|
||||
(Self::Unknown | Self::Null, value) | (value, Self::Unknown | Self::Null) => value,
|
||||
(left, right) if left == right => left,
|
||||
_ => Self::Mixed,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "dynamic",
|
||||
Self::Null => "null",
|
||||
Self::Boolean => "boolean",
|
||||
Self::Number => "number",
|
||||
Self::String => "string",
|
||||
Self::Object => "object",
|
||||
Self::Array => "array",
|
||||
Self::Mixed => "mixed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_field_types(prefix: &str, value: &Value, fields: &mut BTreeMap<String, FieldType>) {
|
||||
let Value::Object(map) = value else {
|
||||
return;
|
||||
};
|
||||
for (name, value) in map {
|
||||
let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") };
|
||||
let field_type = FieldType::from_value(value);
|
||||
fields.entry(path.clone()).and_modify(|current| *current = current.merge(field_type)).or_insert(field_type);
|
||||
if value.is_object() {
|
||||
collect_field_types(&path, value, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_document_identity(document: Value, primary_key: Option<&str>) -> Result<Value, String> {
|
||||
let mut document =
|
||||
document.as_object().cloned().ok_or_else(|| "Meilisearch returned a non-object document".to_string())?;
|
||||
if let Some(primary_key) = primary_key {
|
||||
if let Some(id) = document.remove(primary_key) {
|
||||
document.insert("_id".to_string(), id);
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(document))
|
||||
}
|
||||
|
||||
fn parse_document_object(doc_json: &str) -> Result<Map<String, Value>, String> {
|
||||
let value: Value =
|
||||
serde_json::from_str(doc_json).map_err(|error| format!("Invalid Meilisearch document JSON: {error}"))?;
|
||||
value.as_object().cloned().ok_or_else(|| "Meilisearch document must be a JSON object".to_string())
|
||||
}
|
||||
|
||||
const STRING_ID_PREFIX: &str = "__dbx_meilisearch_string_id__";
|
||||
|
||||
fn decoded_identity(id: &str) -> Value {
|
||||
if let Some(encoded) = id.strip_prefix(STRING_ID_PREFIX) {
|
||||
if let Ok(Value::String(value)) = serde_json::from_str::<Value>(encoded) {
|
||||
return Value::String(value);
|
||||
}
|
||||
}
|
||||
match serde_json::from_str::<Value>(id) {
|
||||
Ok(value @ (Value::String(_) | Value::Number(_))) => value,
|
||||
_ => Value::String(id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_path(id: &str) -> String {
|
||||
value_to_id(&decoded_identity(id))
|
||||
}
|
||||
|
||||
async fn submit_documents(
|
||||
client: &MeilisearchClient,
|
||||
index: &str,
|
||||
method: Method,
|
||||
documents: Vec<Value>,
|
||||
) -> Result<(), String> {
|
||||
let response = client
|
||||
.request(method, &format!("/indexes/{}/documents", encode_path_segment(index)))
|
||||
.json(&documents)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let task = task_from_response(response, "document write").await?;
|
||||
wait_for_task(client, task.task_uid).await
|
||||
}
|
||||
|
||||
pub async fn insert_document(client: &MeilisearchClient, index: &str, doc_json: &str) -> Result<String, String> {
|
||||
let index_info = index_info(client, index).await?;
|
||||
let mut document = parse_document_object(doc_json)?;
|
||||
let metadata_id = document.remove("_id");
|
||||
if let (Some(primary_key), Some(id)) = (index_info.primary_key.as_deref(), metadata_id) {
|
||||
document.insert(primary_key.to_string(), id);
|
||||
}
|
||||
submit_documents(client, index, Method::POST, vec![Value::Object(document.clone())]).await?;
|
||||
Ok(index_info
|
||||
.primary_key
|
||||
.and_then(|name| document.get(&name).cloned())
|
||||
.map(|value| value_to_id(&value))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn update_document(client: &MeilisearchClient, index: &str, id: &str, doc_json: &str) -> Result<u64, String> {
|
||||
let index_info = index_info(client, index).await?;
|
||||
let primary_key =
|
||||
index_info.primary_key.ok_or_else(|| format!("Meilisearch index '{}' has no primary key", index_info.uid))?;
|
||||
let mut document = parse_document_object(doc_json)?;
|
||||
document.remove("_id");
|
||||
document.insert(primary_key, decoded_identity(id));
|
||||
// Meilisearch uses POST for full replacement and PUT for partial updates.
|
||||
// DBX sends the complete edited document so removed fields must disappear.
|
||||
submit_documents(client, index, Method::POST, vec![Value::Object(document)]).await?;
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
pub async fn delete_document(client: &MeilisearchClient, index: &str, id: &str) -> Result<u64, String> {
|
||||
let response = client
|
||||
.delete(&format!(
|
||||
"/indexes/{}/documents/{}",
|
||||
encode_path_segment(index),
|
||||
encode_path_segment(&identity_path(id))
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let task = task_from_response(response, "document deletion").await?;
|
||||
wait_for_task(client, task.task_uid).await?;
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchDocumentUpdate {
|
||||
pub id: String,
|
||||
pub doc_json: String,
|
||||
}
|
||||
|
||||
pub async fn save_document_batch(
|
||||
client: &MeilisearchClient,
|
||||
index: &str,
|
||||
updates: &[MeilisearchDocumentUpdate],
|
||||
delete_ids: &[String],
|
||||
inserts: &[String],
|
||||
) -> Result<u64, String> {
|
||||
let index_info =
|
||||
if updates.is_empty() && inserts.is_empty() { None } else { Some(index_info(client, index).await?) };
|
||||
|
||||
if !updates.is_empty() {
|
||||
let index_info = index_info.as_ref().expect("index info is loaded for document writes");
|
||||
let primary_key = index_info
|
||||
.primary_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| format!("Meilisearch index '{}' has no primary key", index_info.uid))?;
|
||||
let documents = updates
|
||||
.iter()
|
||||
.map(|update| {
|
||||
let mut document = parse_document_object(&update.doc_json)?;
|
||||
document.remove("_id");
|
||||
document.insert(primary_key.to_string(), decoded_identity(&update.id));
|
||||
Ok(Value::Object(document))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
submit_documents(client, index, Method::POST, documents).await?;
|
||||
}
|
||||
|
||||
if !delete_ids.is_empty() {
|
||||
let ids = delete_ids.iter().map(|id| decoded_identity(id)).collect::<Vec<_>>();
|
||||
let response = client
|
||||
.post(&format!("/indexes/{}/documents/delete-batch", encode_path_segment(index)))
|
||||
.json(&ids)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let task = task_from_response(response, "document batch deletion").await?;
|
||||
wait_for_task(client, task.task_uid).await?;
|
||||
}
|
||||
|
||||
if !inserts.is_empty() {
|
||||
let index_info = index_info.as_ref().expect("index info is loaded for document writes");
|
||||
let documents = inserts
|
||||
.iter()
|
||||
.map(|doc_json| {
|
||||
let mut document = parse_document_object(doc_json)?;
|
||||
let metadata_id = document.remove("_id");
|
||||
if let (Some(primary_key), Some(id)) = (index_info.primary_key.as_deref(), metadata_id) {
|
||||
document.insert(primary_key.to_string(), id);
|
||||
}
|
||||
Ok(Value::Object(document))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
submit_documents(client, index, Method::POST, documents).await?;
|
||||
}
|
||||
|
||||
Ok((updates.len() + delete_ids.len() + inserts.len()) as u64)
|
||||
}
|
||||
|
||||
fn value_to_id(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(value) => value.clone(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskSummary {
|
||||
task_uid: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TaskStatus {
|
||||
status: String,
|
||||
error: Option<Value>,
|
||||
}
|
||||
|
||||
async fn task_from_response(response: reqwest::Response, context: &str) -> Result<TaskSummary, String> {
|
||||
let value = response_json(response, context).await?;
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch task parse error: {error}"))
|
||||
}
|
||||
|
||||
async fn wait_for_task(client: &MeilisearchClient, task_uid: u64) -> Result<(), String> {
|
||||
let deadline = Instant::now() + TASK_WAIT_TIMEOUT;
|
||||
loop {
|
||||
let response = client
|
||||
.get(&format!("/tasks/{task_uid}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Meilisearch task request failed: {error}"))?;
|
||||
let value = response_json(response, "task lookup").await?;
|
||||
let task: TaskStatus =
|
||||
serde_json::from_value(value).map_err(|error| format!("Meilisearch task parse error: {error}"))?;
|
||||
match task.status.as_str() {
|
||||
"succeeded" => return Ok(()),
|
||||
"failed" | "canceled" => {
|
||||
let detail = task.error.map(|error| error.to_string()).unwrap_or_else(|| task.status.clone());
|
||||
return Err(format!("Meilisearch task {task_uid} {}: {detail}", task.status));
|
||||
}
|
||||
_ if Instant::now() >= deadline => {
|
||||
return Err(format!("Meilisearch task {task_uid} did not finish within 60 seconds"));
|
||||
}
|
||||
_ => tokio::time::sleep(TASK_POLL_INTERVAL).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn meilisearch_filter_from_request(filter: Option<&str>, primary_key: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty() && *value != "{}") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: Value =
|
||||
serde_json::from_str(filter).map_err(|error| format!("Invalid Meilisearch filter JSON: {error}"))?;
|
||||
if let Some(raw) = value.get("$meiliFilter").and_then(Value::as_str) {
|
||||
return Ok(Some(raw.to_string()));
|
||||
}
|
||||
filter_expression(&value, primary_key).map(Some)
|
||||
}
|
||||
|
||||
fn filter_expression(value: &Value, primary_key: Option<&str>) -> Result<String, String> {
|
||||
let object = value.as_object().ok_or_else(|| "Meilisearch filter must be a JSON object".to_string())?;
|
||||
let mut expressions = Vec::new();
|
||||
for (field, condition) in object {
|
||||
match field.as_str() {
|
||||
"$and" | "$or" => {
|
||||
let values =
|
||||
condition.as_array().ok_or_else(|| format!("Meilisearch {field} filter must be an array"))?;
|
||||
let operator = if field == "$and" { " AND " } else { " OR " };
|
||||
let nested = values
|
||||
.iter()
|
||||
.map(|value| filter_expression(value, primary_key))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|expression| format!("({expression})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(operator);
|
||||
if !nested.is_empty() {
|
||||
expressions.push(nested);
|
||||
}
|
||||
}
|
||||
"$meiliFilter" => {
|
||||
let raw = condition.as_str().ok_or_else(|| "$meiliFilter must be a string".to_string())?;
|
||||
if !raw.trim().is_empty() {
|
||||
expressions.push(raw.trim().to_string());
|
||||
}
|
||||
}
|
||||
_ => expressions.push(field_filter_expression(field, condition, primary_key)?),
|
||||
}
|
||||
}
|
||||
if expressions.is_empty() {
|
||||
return Err("Meilisearch filter is empty".to_string());
|
||||
}
|
||||
Ok(expressions.into_iter().map(|expression| format!("({expression})")).collect::<Vec<_>>().join(" AND "))
|
||||
}
|
||||
|
||||
fn field_filter_expression(field: &str, condition: &Value, primary_key: Option<&str>) -> Result<String, String> {
|
||||
let field = if field == "_id" { primary_key.unwrap_or(field) } else { field };
|
||||
validate_filter_field(field)?;
|
||||
let Some(operators) = condition.as_object().filter(|value| value.keys().any(|key| key.starts_with('$'))) else {
|
||||
return Ok(if condition.is_null() {
|
||||
format!("{field} IS NULL")
|
||||
} else {
|
||||
format!("{field} = {}", filter_value(condition)?)
|
||||
});
|
||||
};
|
||||
|
||||
let mut expressions = Vec::new();
|
||||
for (operator, value) in operators {
|
||||
match operator.as_str() {
|
||||
"$options" => {}
|
||||
"$ne" if value.is_null() => expressions.push(format!("NOT {field} IS NULL")),
|
||||
"$ne" => expressions.push(format!("{field} != {}", filter_value(value)?)),
|
||||
"$gt" => expressions.push(format!("{field} > {}", filter_value(value)?)),
|
||||
"$gte" => expressions.push(format!("{field} >= {}", filter_value(value)?)),
|
||||
"$lt" => expressions.push(format!("{field} < {}", filter_value(value)?)),
|
||||
"$lte" => expressions.push(format!("{field} <= {}", filter_value(value)?)),
|
||||
"$regex" | "$not" => {
|
||||
return Err(format!(
|
||||
"Meilisearch contains filters require an experimental server feature; use a raw $meiliFilter only when the target server enables it (field '{field}')"
|
||||
));
|
||||
}
|
||||
_ => return Err(format!("Unsupported Meilisearch filter operator for field '{field}': {operator}")),
|
||||
}
|
||||
}
|
||||
if expressions.is_empty() {
|
||||
return Err(format!("Meilisearch filter for field '{field}' is empty"));
|
||||
}
|
||||
Ok(expressions.into_iter().map(|expression| format!("({expression})")).collect::<Vec<_>>().join(" AND "))
|
||||
}
|
||||
|
||||
fn validate_filter_field(field: &str) -> Result<(), String> {
|
||||
if !field.is_empty()
|
||||
&& field.split('.').all(|part| {
|
||||
!part.is_empty() && part.chars().all(|character| character.is_alphanumeric() || character == '_')
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("Unsupported Meilisearch filter field: {field}"))
|
||||
}
|
||||
|
||||
fn filter_value(value: &Value) -> Result<String, String> {
|
||||
match value {
|
||||
Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(value.to_string()),
|
||||
_ => Err("Meilisearch filter values must be strings, numbers, booleans, or null".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn meilisearch_sort_from_request(sort: Option<&str>, primary_key: Option<&str>) -> Result<Option<Vec<String>>, String> {
|
||||
let Some(sort) = sort.map(str::trim).filter(|value| !value.is_empty() && *value != "{}") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: Value = serde_json::from_str(sort).map_err(|error| format!("Invalid Meilisearch sort JSON: {error}"))?;
|
||||
let object = value.as_object().ok_or_else(|| "Meilisearch sort must be a JSON object".to_string())?;
|
||||
let mut result = Vec::new();
|
||||
for (field, direction) in object {
|
||||
let field = if field == "_id" { primary_key.unwrap_or(field) } else { field };
|
||||
validate_filter_field(field)?;
|
||||
let direction = match direction {
|
||||
Value::Number(number) if number.as_i64().unwrap_or(1) < 0 => "desc",
|
||||
Value::String(value) if value.eq_ignore_ascii_case("desc") => "desc",
|
||||
_ => "asc",
|
||||
};
|
||||
result.push(format!("{field}:{direction}"));
|
||||
}
|
||||
Ok((!result.is_empty()).then_some(result))
|
||||
}
|
||||
|
||||
pub async fn execute_rest_query(client: &MeilisearchClient, input: &str) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err("Meilisearch REST request is empty".to_string());
|
||||
}
|
||||
let (request_line, body) = input.split_once('\n').map_or((input, ""), |(line, body)| (line.trim(), body.trim()));
|
||||
let (method, path) = parse_rest_request_line(request_line)?;
|
||||
let mut request = client.request(method, &path);
|
||||
if !body.is_empty() {
|
||||
let body: Value =
|
||||
serde_json::from_str(body).map_err(|error| format!("Invalid Meilisearch REST JSON body: {error}"))?;
|
||||
request = request.json(&body);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| format!("Meilisearch request failed: {error}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let (body, truncated) = read_bounded_rest_response(response).await?;
|
||||
Ok(raw_response_result(status, body, start, truncated))
|
||||
}
|
||||
|
||||
async fn read_bounded_rest_response(mut response: reqwest::Response) -> Result<(String, bool), String> {
|
||||
let read_limit = REST_RESPONSE_MAX_BYTES.saturating_add(1);
|
||||
let capacity = response.content_length().unwrap_or(0).min(read_limit as u64) as usize;
|
||||
let mut body = Vec::with_capacity(capacity);
|
||||
let mut truncated = response.content_length().is_some_and(|length| length > REST_RESPONSE_MAX_BYTES as u64);
|
||||
while body.len() < read_limit {
|
||||
let Some(chunk) = response.chunk().await.map_err(|error| format!("Meilisearch response error: {error}"))?
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let remaining = read_limit.saturating_sub(body.len());
|
||||
if chunk.len() > remaining {
|
||||
body.extend_from_slice(&chunk[..remaining]);
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
if body.len() > REST_RESPONSE_MAX_BYTES {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok((decode_bounded_rest_body(&body), truncated))
|
||||
}
|
||||
|
||||
fn decode_bounded_rest_body(body: &[u8]) -> String {
|
||||
let body = &body[..body.len().min(REST_RESPONSE_MAX_BYTES)];
|
||||
match std::str::from_utf8(body) {
|
||||
Ok(text) => text.to_string(),
|
||||
Err(error) if error.error_len().is_none() => String::from_utf8_lossy(&body[..error.valid_up_to()]).into_owned(),
|
||||
Err(_) => String::from_utf8_lossy(body).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rest_request_line(line: &str) -> Result<(Method, String), String> {
|
||||
let mut parts = line.split_whitespace();
|
||||
let first = parts.next().ok_or_else(|| "Meilisearch REST request is empty".to_string())?;
|
||||
let (method, path) = if let Some(path) = parts.next() {
|
||||
let method = Method::from_bytes(first.as_bytes()).map_err(|_| format!("Unsupported HTTP method: {first}"))?;
|
||||
if !matches!(method, Method::GET | Method::POST | Method::PUT | Method::PATCH | Method::DELETE | Method::HEAD) {
|
||||
return Err(format!("Unsupported Meilisearch HTTP method: {first}"));
|
||||
}
|
||||
(method, path)
|
||||
} else {
|
||||
(Method::GET, first)
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err("Meilisearch REST request line must be 'METHOD /path'".to_string());
|
||||
}
|
||||
if !path.starts_with('/') {
|
||||
return Err("Meilisearch REST path must start with '/'".to_string());
|
||||
}
|
||||
Ok((method, path.to_string()))
|
||||
}
|
||||
|
||||
fn raw_response_result(status: u16, body: String, start: Instant, truncated: bool) -> QueryResult {
|
||||
let body = if body.trim().is_empty() {
|
||||
"null".to_string()
|
||||
} else if let Ok(value) = serde_json::from_str::<Value>(&body) {
|
||||
serde_json::to_string_pretty(&value).unwrap_or(body)
|
||||
} else {
|
||||
body
|
||||
};
|
||||
QueryResult {
|
||||
columns: vec!["status".to_string(), "response".to_string()],
|
||||
column_types: Vec::new(),
|
||||
column_sortables: Vec::new(),
|
||||
spatial_columns: Vec::new(),
|
||||
spatial_values: Vec::new(),
|
||||
rows: vec![vec![Value::Number(status.into()), Value::String(body)]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated,
|
||||
session_id: None,
|
||||
has_more: false,
|
||||
elasticsearch_raw_body: None,
|
||||
messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_bounded_rest_body, filter_expression, meilisearch_sort_from_request, parse_rest_request_line};
|
||||
use reqwest::Method;
|
||||
|
||||
#[test]
|
||||
fn translates_document_filters_to_meilisearch_syntax() {
|
||||
let filter = serde_json::json!({
|
||||
"$and": [
|
||||
{ "status": "published" },
|
||||
{ "rating": { "$gte": 8 } }
|
||||
]
|
||||
});
|
||||
let expression = filter_expression(&filter, Some("id")).unwrap();
|
||||
assert!(expression.contains("status = \"published\""));
|
||||
assert!(expression.contains("rating >= 8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_contains_without_assuming_experimental_server_features() {
|
||||
let error = filter_expression(&serde_json::json!({ "title": { "$regex": "space" } }), Some("id")).unwrap_err();
|
||||
assert!(error.contains("experimental server feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_document_sort_to_meilisearch_syntax() {
|
||||
assert_eq!(
|
||||
meilisearch_sort_from_request(Some(r#"{"rating":-1,"title":"asc"}"#), Some("id")).unwrap(),
|
||||
Some(vec!["rating:desc".to_string(), "title:asc".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_document_identity_alias_to_primary_key() {
|
||||
assert_eq!(
|
||||
filter_expression(&serde_json::json!({ "_id": "001" }), Some("movie_id")).unwrap(),
|
||||
"(movie_id = \"001\")"
|
||||
);
|
||||
assert_eq!(
|
||||
meilisearch_sort_from_request(Some(r#"{"_id":1}"#), Some("movie_id")).unwrap(),
|
||||
Some(vec!["movie_id:asc".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rest_request_lines() {
|
||||
assert_eq!(parse_rest_request_line("GET /version").unwrap(), (Method::GET, "/version".to_string()));
|
||||
assert_eq!(parse_rest_request_line("/health").unwrap(), (Method::GET, "/health".to_string()));
|
||||
assert!(parse_rest_request_line("CONNECT /health")
|
||||
.unwrap_err()
|
||||
.contains("Unsupported Meilisearch HTTP method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_rest_body_does_not_end_with_partial_utf8() {
|
||||
let mut body = vec![b'a'; super::REST_RESPONSE_MAX_BYTES - 1];
|
||||
body.extend_from_slice("界".as_bytes());
|
||||
assert_eq!(decode_bounded_rest_body(&body), "a".repeat(super::REST_RESPONSE_MAX_BYTES - 1));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod hbase_driver;
|
||||
pub mod http_tunnel;
|
||||
pub mod influxdb_driver;
|
||||
pub mod manticoresearch;
|
||||
pub mod meilisearch_driver;
|
||||
pub mod mongo_driver;
|
||||
pub mod mysql;
|
||||
pub mod mysql_compatible;
|
||||
|
||||
@@ -56,6 +56,7 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
|
||||
},
|
||||
PoolKind::Elasticsearch(_) => Ok(vec!["default".to_string()]),
|
||||
PoolKind::Easysearch(_) => Ok(vec!["default".to_string()]),
|
||||
PoolKind::Meilisearch(_) => Ok(vec!["default".to_string()]),
|
||||
PoolKind::VectorDb(client) => vector_driver::list_databases(client).await,
|
||||
PoolKind::Agent(client) => {
|
||||
let mut client = client.lock().await;
|
||||
@@ -253,6 +254,10 @@ pub async fn list_collections_core(
|
||||
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::Meilisearch(client) => {
|
||||
let names = sort_names(crate::db::meilisearch_driver::list_indexes(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;
|
||||
@@ -425,6 +430,11 @@ pub async fn find_documents_core(
|
||||
drop(connections);
|
||||
easysearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
crate::db::meilisearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
|
||||
}
|
||||
PoolKind::VectorDb(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
@@ -509,6 +519,11 @@ pub async fn insert_document_core(
|
||||
drop(connections);
|
||||
easysearch_driver::insert_document(&client, collection, doc_json, routing).await
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
crate::db::meilisearch_driver::insert_document(&client, collection, doc_json).await
|
||||
}
|
||||
PoolKind::Agent(client) => {
|
||||
let mut client = client.lock().await;
|
||||
let result: serde_json::Value = client
|
||||
@@ -570,6 +585,11 @@ pub async fn update_document_core(
|
||||
drop(connections);
|
||||
easysearch_driver::update_document(&client, collection, id, doc_json, routing).await
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
crate::db::meilisearch_driver::update_document(&client, collection, id, doc_json).await
|
||||
}
|
||||
PoolKind::Agent(client) => {
|
||||
let mut client = client.lock().await;
|
||||
let result: serde_json::Value = client
|
||||
@@ -622,6 +642,11 @@ pub async fn delete_document_core_with_type(
|
||||
drop(connections);
|
||||
easysearch_driver::delete_document(&client, collection, id, document_type, routing).await
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
crate::db::meilisearch_driver::delete_document(&client, collection, id).await
|
||||
}
|
||||
PoolKind::Agent(client) => {
|
||||
let mut client = client.lock().await;
|
||||
let result: serde_json::Value =
|
||||
@@ -632,6 +657,26 @@ pub async fn delete_document_core_with_type(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_meilisearch_document_batch_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
collection: &str,
|
||||
updates: &[crate::db::meilisearch_driver::MeilisearchDocumentUpdate],
|
||||
delete_ids: &[String],
|
||||
inserts: &[String],
|
||||
) -> Result<u64, String> {
|
||||
ensure_document_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
crate::db::meilisearch_driver::save_document_batch(&client, collection, updates, delete_ids, inserts).await
|
||||
}
|
||||
_ => Err("Not a Meilisearch connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
||||
@@ -517,6 +517,8 @@ pub enum DatabaseType {
|
||||
Elasticsearch,
|
||||
#[serde(rename = "easysearch")]
|
||||
Easysearch,
|
||||
#[serde(rename = "meilisearch")]
|
||||
Meilisearch,
|
||||
Hbase,
|
||||
#[serde(rename = "qdrant")]
|
||||
Qdrant,
|
||||
@@ -1092,6 +1094,7 @@ impl ConnectionConfig {
|
||||
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
|
||||
DatabaseType::Elasticsearch
|
||||
| DatabaseType::Easysearch
|
||||
| DatabaseType::Meilisearch
|
||||
| DatabaseType::Hbase
|
||||
| DatabaseType::Qdrant
|
||||
| DatabaseType::Milvus
|
||||
@@ -1256,6 +1259,7 @@ impl ConnectionConfig {
|
||||
}
|
||||
DatabaseType::Elasticsearch
|
||||
| DatabaseType::Easysearch
|
||||
| DatabaseType::Meilisearch
|
||||
| DatabaseType::Hbase
|
||||
| DatabaseType::Qdrant
|
||||
| DatabaseType::Milvus
|
||||
@@ -2327,6 +2331,12 @@ mod tests {
|
||||
assert_eq!(default_query_timeout_secs(), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meilisearch_database_type_serializes_stably() {
|
||||
assert_eq!(serde_json::to_string(&DatabaseType::Meilisearch).unwrap(), "\"meilisearch\"");
|
||||
assert_eq!(serde_json::from_str::<DatabaseType>("\"meilisearch\"").unwrap(), DatabaseType::Meilisearch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_test_result_uses_camel_case_and_omits_missing_details() {
|
||||
let result =
|
||||
|
||||
@@ -1592,6 +1592,23 @@ async fn do_execute_typed(
|
||||
}
|
||||
result
|
||||
}
|
||||
PoolKind::Meilisearch(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::meilisearch_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();
|
||||
@@ -3105,6 +3122,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
|
||||
| PoolKind::MongoDb(_)
|
||||
| PoolKind::Elasticsearch(_)
|
||||
| PoolKind::Easysearch(_)
|
||||
| PoolKind::Meilisearch(_)
|
||||
| PoolKind::VectorDb(_)
|
||||
| PoolKind::InfluxDb(_)
|
||||
| PoolKind::VictoriaMetrics(_)
|
||||
@@ -3350,6 +3368,7 @@ pub async fn execute_statements_in_transaction_on_pool_typed(
|
||||
| PoolKind::MongoDb(_)
|
||||
| PoolKind::Elasticsearch(_)
|
||||
| PoolKind::Easysearch(_)
|
||||
| PoolKind::Meilisearch(_)
|
||||
| PoolKind::VectorDb(_)
|
||||
| PoolKind::InfluxDb(_)
|
||||
| PoolKind::VictoriaMetrics(_)
|
||||
|
||||
@@ -2225,6 +2225,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::Meilisearch(client) => db::meilisearch_driver::list_indexes(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)),
|
||||
@@ -5830,6 +5834,9 @@ async fn get_columns_core_for_session_inner(
|
||||
PoolKind::Easysearch(client) => {
|
||||
db::easysearch_driver::get_columns(client, table).await.map(deduplicate_column_infos)
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
db::meilisearch_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)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ pub(in crate::schema) async fn list_tables(
|
||||
PoolKind::Easysearch(client) => {
|
||||
db::easysearch_driver::list_indices(client).await.map(|names| collection_names_to_tables(names, "INDEX"))
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
db::meilisearch_driver::list_indexes(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
|
||||
@@ -155,6 +158,7 @@ pub(in crate::schema) async fn get_columns(
|
||||
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::Meilisearch(client) => db::meilisearch_driver::get_columns(client, table).await,
|
||||
PoolKind::HBase(client) => db::hbase_driver::get_columns(client, database, table).await,
|
||||
PoolKind::VectorDb(_) => Ok(vec![]),
|
||||
_ => Ok(vec![]),
|
||||
|
||||
@@ -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 | Easysearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j | Cassandra
|
||||
| Etcd | ZooKeeper | Nacos | Consul | InfluxDb | VictoriaMetrics | MessageQueue | Mqtt | Hbase => {
|
||||
Redis | MongoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j
|
||||
| Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb | VictoriaMetrics | MessageQueue | Mqtt | Hbase => {
|
||||
conservative_ansi(db_type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
use dbx_core::db::meilisearch_driver::{
|
||||
execute_rest_query, find_documents, get_columns, insert_document, list_indexes, save_document_batch,
|
||||
test_connection, MeilisearchClient, MeilisearchDocumentUpdate,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn live_client() -> MeilisearchClient {
|
||||
let url = std::env::var("DBX_LIVE_MEILISEARCH_URL").expect("DBX_LIVE_MEILISEARCH_URL");
|
||||
let api_key = std::env::var("DBX_LIVE_MEILISEARCH_API_KEY").ok();
|
||||
MeilisearchClient::new(&url, api_key.as_deref(), url.starts_with("https://"), None, Duration::from_secs(15))
|
||||
.expect("create Meilisearch client")
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
format!("dbx_live_{nanos}")
|
||||
}
|
||||
|
||||
async fn rest(client: &MeilisearchClient, request: &str) -> serde_json::Value {
|
||||
let result = execute_rest_query(client, request).await.expect("execute REST request");
|
||||
assert_eq!(result.rows.len(), 1);
|
||||
let status = result.rows[0][0].as_u64().expect("status code");
|
||||
assert!(status < 400, "request failed: {}", result.rows[0][1]);
|
||||
serde_json::from_str(result.rows[0][1].as_str().expect("response body")).expect("JSON response")
|
||||
}
|
||||
|
||||
async fn wait_for_task(client: &MeilisearchClient, task_uid: u64) {
|
||||
for _ in 0..100 {
|
||||
let task = rest(client, &format!("GET /tasks/{task_uid}")).await;
|
||||
match task["status"].as_str() {
|
||||
Some("succeeded") => return,
|
||||
Some("failed") | Some("canceled") => panic!("task {task_uid} failed: {}", task["error"]),
|
||||
_ => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
panic!("task {task_uid} did not finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_MEILISEARCH_URL and optional DBX_LIVE_MEILISEARCH_API_KEY"]
|
||||
async fn meilisearch_document_lifecycle() {
|
||||
let client = live_client();
|
||||
test_connection(&client, Duration::from_secs(15)).await.expect("connect");
|
||||
|
||||
let index = unique_index();
|
||||
let create = rest(&client, &format!("POST /indexes\n{}", json!({ "uid": index, "primaryKey": "id" }))).await;
|
||||
let create_task = create["taskUid"].as_u64().expect("create task uid");
|
||||
wait_for_task(&client, create_task).await;
|
||||
|
||||
let settings = rest(
|
||||
&client,
|
||||
&format!(
|
||||
"PATCH /indexes/{index}/settings\n{}",
|
||||
json!({ "filterableAttributes": ["status", "rating", "title"], "sortableAttributes": ["rating"] })
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let settings_task = settings["taskUid"].as_u64().expect("settings task uid");
|
||||
wait_for_task(&client, settings_task).await;
|
||||
|
||||
insert_document(
|
||||
&client,
|
||||
&index,
|
||||
&json!({ "_id": "001", "title": "Space Opera", "status": "published", "rating": 8, "obsolete": true })
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("insert string id");
|
||||
insert_document(
|
||||
&client,
|
||||
&index,
|
||||
&json!({ "_id": 2, "title": "Ocean Story", "status": "draft", "rating": 5 }).to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("insert numeric id");
|
||||
|
||||
assert!(list_indexes(&client).await.expect("list indexes").contains(&index));
|
||||
let documents = find_documents(
|
||||
&client,
|
||||
&index,
|
||||
0,
|
||||
20,
|
||||
Some(r#"{"$and":[{"status":"published"},{"rating":{"$gte":8}}]}"#),
|
||||
Some(r#"{"rating":-1}"#),
|
||||
)
|
||||
.await
|
||||
.expect("find documents");
|
||||
assert_eq!(documents.total, 1);
|
||||
assert_eq!(documents.documents[0]["_id"], "001");
|
||||
|
||||
let columns = get_columns(&client, &index).await.expect("get columns");
|
||||
assert!(columns.iter().any(|column| column.name == "id" && column.is_primary_key));
|
||||
assert!(columns.iter().any(|column| column.name == "title"));
|
||||
|
||||
let affected = save_document_batch(
|
||||
&client,
|
||||
&index,
|
||||
&[
|
||||
MeilisearchDocumentUpdate {
|
||||
id: "__dbx_meilisearch_string_id__\"001\"".to_string(),
|
||||
doc_json: json!({ "title": "Space Opera Revised", "status": "published", "rating": 9 }).to_string(),
|
||||
},
|
||||
MeilisearchDocumentUpdate {
|
||||
id: "2".to_string(),
|
||||
doc_json: json!({ "title": "Ocean Story Revised", "status": "draft", "rating": 6 }).to_string(),
|
||||
},
|
||||
],
|
||||
&["2".to_string()],
|
||||
&[
|
||||
json!({ "_id": "003", "title": "Forest Story", "status": "published", "rating": 7 }).to_string(),
|
||||
json!({ "_id": 4, "title": "Desert Story", "status": "draft", "rating": 4 }).to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("save document batch");
|
||||
assert_eq!(affected, 5);
|
||||
|
||||
let remaining = find_documents(&client, &index, 0, 20, None, None).await.expect("fetch remaining documents");
|
||||
assert_eq!(remaining.total, 3);
|
||||
let revised = remaining.documents.iter().find(|document| document["_id"] == "001").expect("revised document");
|
||||
assert_eq!(revised["title"], "Space Opera Revised");
|
||||
assert!(revised.get("obsolete").is_none(), "full replacement must remove deleted fields");
|
||||
assert!(remaining.documents.iter().all(|document| document["_id"] != 2));
|
||||
assert!(remaining.documents.iter().any(|document| document["_id"] == "003"));
|
||||
assert!(remaining.documents.iter().any(|document| document["_id"] == 4));
|
||||
|
||||
let delete = rest(&client, &format!("DELETE /indexes/{index}")).await;
|
||||
let delete_task = delete["taskUid"].as_u64().expect("delete task uid");
|
||||
wait_for_task(&client, delete_task).await;
|
||||
}
|
||||
@@ -774,6 +774,7 @@ async fn main() {
|
||||
.route("/document-store/insert-document", post(routes::document_store::insert_document))
|
||||
.route("/document-store/update-document", post(routes::document_store::update_document))
|
||||
.route("/document-store/delete-document", post(routes::document_store::delete_document))
|
||||
.route("/document-store/save-meilisearch-batch", post(routes::document_store::save_meilisearch_batch))
|
||||
.route("/mongo/find-documents", post(routes::mongo::find_documents))
|
||||
.route("/mongo/parse-shell-command", post(routes::mongo::parse_shell_command))
|
||||
.route("/mongo/explain-find", post(routes::mongo::explain_find))
|
||||
|
||||
@@ -112,6 +112,16 @@ pub struct DocumentDeleteRequest {
|
||||
pub document_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeilisearchBatchSaveRequest {
|
||||
pub connection_id: String,
|
||||
pub collection: String,
|
||||
pub updates: Vec<dbx_core::db::meilisearch_driver::MeilisearchDocumentUpdate>,
|
||||
pub delete_ids: Vec<String>,
|
||||
pub inserts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridFsBucketRequest {
|
||||
@@ -286,6 +296,24 @@ pub async fn delete_document(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn save_meilisearch_batch(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MeilisearchBatchSaveRequest>,
|
||||
) -> Result<Json<u64>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Save").await?;
|
||||
let result = dbx_core::document_ops::save_meilisearch_document_batch_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.collection,
|
||||
&req.updates,
|
||||
&req.delete_ids,
|
||||
&req.inserts,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_gridfs_files(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<GridFsFileListRequest>,
|
||||
|
||||
@@ -21,6 +21,7 @@ const databaseSupport = [
|
||||
{ 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: "#836eff" },
|
||||
{ name: "Meilisearch", icon: "/icons/database/meilisearch.svg", tone: "#ff5caa" },
|
||||
{ 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" },
|
||||
@@ -151,22 +152,22 @@ export default async function DatabasesPage({ params }: { params: Promise<{ lang
|
||||
const isCta = "href" in db && db.href;
|
||||
const CardTag = isCta ? "a" : "div";
|
||||
return (
|
||||
<CardTag
|
||||
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:px-1.5 max-[760px]:py-2.5 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
|
||||
key={db.name}
|
||||
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
style={{ "--db-tone": db.tone } as CSSProperties}
|
||||
data-stagger
|
||||
>
|
||||
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px] max-[760px]:size-8 max-[760px]:mb-2">
|
||||
{isCta ? (
|
||||
<span className="grid place-items-center w-10 h-10 rounded-full border-2 border-dashed text-landing-blue border-landing-blue text-2xl leading-none">+</span>
|
||||
) : (
|
||||
<img src={db.icon} alt="" width={38} height={38} loading="lazy" decoding="async" className="block w-[38px] h-[38px] object-contain max-[760px]:size-7" />
|
||||
)}
|
||||
</div>
|
||||
<strong className={`text-sm font-[650] leading-[1.2] text-center max-[760px]:text-[11px] ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
|
||||
</CardTag>
|
||||
<CardTag
|
||||
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:px-1.5 max-[760px]:py-2.5 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
|
||||
key={db.name}
|
||||
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
style={{ "--db-tone": db.tone } as CSSProperties}
|
||||
data-stagger
|
||||
>
|
||||
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px] max-[760px]:size-8 max-[760px]:mb-2">
|
||||
{isCta ? (
|
||||
<span className="grid place-items-center w-10 h-10 rounded-full border-2 border-dashed text-landing-blue border-landing-blue text-2xl leading-none">+</span>
|
||||
) : (
|
||||
<img src={db.icon} alt="" width={38} height={38} loading="lazy" decoding="async" className="block w-[38px] h-[38px] object-contain max-[760px]:size-7" />
|
||||
)}
|
||||
</div>
|
||||
<strong className={`text-sm font-[650] leading-[1.2] text-center max-[760px]:text-[11px] ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
|
||||
</CardTag>
|
||||
);
|
||||
})}
|
||||
</ExpandableDatabaseGrid>
|
||||
@@ -177,11 +178,7 @@ export default async function DatabasesPage({ params }: { params: Promise<{ lang
|
||||
<div className="landing-glass-card rounded-[10px] p-8 text-center max-w-[640px] mx-auto">
|
||||
<h2 className="text-[21px] font-[720]">{t.ctaTitle}</h2>
|
||||
<p className="mt-2 text-landing-muted text-sm leading-[1.65]">{t.ctaDesc}</p>
|
||||
<Link
|
||||
href="https://github.com/t8y2/dbx/discussions"
|
||||
target="_blank"
|
||||
className="landing-final-link inline-flex items-center justify-center min-h-[42px] rounded-[7px] px-5 mt-5 text-sm font-[650]"
|
||||
>
|
||||
<Link href="https://github.com/t8y2/dbx/discussions" target="_blank" className="landing-final-link inline-flex items-center justify-center min-h-[42px] rounded-[7px] px-5 mt-5 text-sm font-[650]">
|
||||
{t.ctaLink}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -191,7 +188,9 @@ export default async function DatabasesPage({ params }: { params: Promise<{ lang
|
||||
<div className="max-w-[1180px] mx-auto px-7 pb-20 text-center max-[760px]:px-[18px]">
|
||||
<Link href={`/${l}/docs/databases`} className="landing-inline-link inline-flex items-center gap-[7px] text-sm font-[650]">
|
||||
{t.footerLink}
|
||||
<svg width={15} height={15} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
<svg width={15} height={15} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
+21
-24
@@ -59,6 +59,7 @@ const databaseSupport = [
|
||||
{ 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: "#836eff" },
|
||||
{ name: "Meilisearch", icon: "/icons/database/meilisearch.svg", tone: "#ff5caa" },
|
||||
{ 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" },
|
||||
@@ -520,7 +521,9 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
||||
<Spotlight />
|
||||
<div className="relative z-[1] max-w-[1180px] mx-auto px-7 max-[1040px]:max-w-[920px] max-[760px]:px-[18px]">
|
||||
<div className="landing-hero-copy relative z-[6] grid justify-items-center max-w-[900px] mx-auto text-center max-[1040px]:max-w-[760px]">
|
||||
<h1 id="landing-title" className="min-w-0 m-0 text-[clamp(36px,4.2vw,56px)] font-[820] leading-[1.06] text-landing-ink whitespace-nowrap max-[760px]:max-w-[12ch] max-[760px]:whitespace-normal max-[760px]:text-balance max-[760px]:text-[clamp(29px,8.7vw,38px)] max-[760px]:leading-[1.08]">{t.heroTitle}</h1>
|
||||
<h1 id="landing-title" className="min-w-0 m-0 text-[clamp(36px,4.2vw,56px)] font-[820] leading-[1.06] text-landing-ink whitespace-nowrap max-[760px]:max-w-[12ch] max-[760px]:whitespace-normal max-[760px]:text-balance max-[760px]:text-[clamp(29px,8.7vw,38px)] max-[760px]:leading-[1.08]">
|
||||
{t.heroTitle}
|
||||
</h1>
|
||||
<p className="landing-hero-subtitle min-w-0 mt-5 mx-auto text-[17px] font-[460] leading-[1.8] whitespace-nowrap max-[900px]:max-w-[680px] max-[900px]:whitespace-normal max-[760px]:max-w-[320px] max-[760px]:text-[15px] max-[760px]:leading-[1.68]">{t.heroSubtitle}</p>
|
||||
<div className="w-full max-w-[520px] mt-10 max-[760px]:mt-7">
|
||||
<InstallTabs lang={l} version={initialDownloadVersion} />
|
||||
@@ -560,13 +563,7 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
||||
</div>
|
||||
<div className="landing-workflow-grid grid grid-cols-4 rounded-[10px] overflow-hidden max-[1040px]:grid-cols-2 max-[760px]:grid-cols-2 max-[360px]:grid-cols-1">
|
||||
{workflowItems.map((item, i) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className={`landing-workflow-card min-h-[250px] p-6 border-r border-r-landing-line max-[760px]:min-h-0 max-[760px]:p-[18px] ${i === workflowItems.length - 1 ? "border-r-0" : ""}`}
|
||||
target="_blank"
|
||||
data-stagger
|
||||
>
|
||||
<Link key={item.title} href={item.href} className={`landing-workflow-card min-h-[250px] p-6 border-r border-r-landing-line max-[760px]:min-h-0 max-[760px]:p-[18px] ${i === workflowItems.length - 1 ? "border-r-0" : ""}`} target="_blank" data-stagger>
|
||||
<item.icon size={20} className="text-landing-blue" />
|
||||
<h3 className="mt-[18px] text-base font-bold">{item.title}</h3>
|
||||
<p className="mt-2.5 text-landing-muted text-[13px] leading-[1.62]">{item.desc}</p>
|
||||
@@ -596,22 +593,22 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
||||
const isCta = "href" in db && db.href;
|
||||
const CardTag = isCta ? "a" : "div";
|
||||
return (
|
||||
<CardTag
|
||||
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:px-1.5 max-[760px]:py-2.5 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
|
||||
key={db.name}
|
||||
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
style={{ "--db-tone": db.tone } as CSSProperties}
|
||||
data-stagger
|
||||
>
|
||||
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px] max-[760px]:size-8 max-[760px]:mb-2">
|
||||
{isCta ? (
|
||||
<span className="grid place-items-center w-10 h-10 rounded-full border-2 border-dashed text-landing-blue border-landing-blue text-2xl leading-none">+</span>
|
||||
) : (
|
||||
<img src={db.icon} alt="" width={38} height={38} loading="lazy" decoding="async" className="block w-[38px] h-[38px] object-contain max-[760px]:size-7" />
|
||||
)}
|
||||
</div>
|
||||
<strong className={`text-sm font-[650] leading-[1.2] text-center max-[760px]:text-[11px] ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
|
||||
</CardTag>
|
||||
<CardTag
|
||||
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:px-1.5 max-[760px]:py-2.5 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
|
||||
key={db.name}
|
||||
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
style={{ "--db-tone": db.tone } as CSSProperties}
|
||||
data-stagger
|
||||
>
|
||||
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px] max-[760px]:size-8 max-[760px]:mb-2">
|
||||
{isCta ? (
|
||||
<span className="grid place-items-center w-10 h-10 rounded-full border-2 border-dashed text-landing-blue border-landing-blue text-2xl leading-none">+</span>
|
||||
) : (
|
||||
<img src={db.icon} alt="" width={38} height={38} loading="lazy" decoding="async" className="block w-[38px] h-[38px] object-contain max-[760px]:size-7" />
|
||||
)}
|
||||
</div>
|
||||
<strong className={`text-sm font-[650] leading-[1.2] text-center max-[760px]:text-[11px] ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
|
||||
</CardTag>
|
||||
);
|
||||
})}
|
||||
</ExpandableDatabaseGrid>
|
||||
|
||||
@@ -30,7 +30,7 @@ Desktop 的文件路径属于本机。Docker/Web 的文件路径属于服务器
|
||||
### 专用数据模型和服务
|
||||
|
||||
- Redis、MongoDB
|
||||
- Elasticsearch、Easysearch、Manticore Search
|
||||
- Elasticsearch、Easysearch、Meilisearch、Manticore Search
|
||||
- Qdrant、Milvus、Weaviate、ChromaDB
|
||||
- HBase
|
||||
- Pulsar、Kafka、RocketMQ、RabbitMQ
|
||||
@@ -94,7 +94,7 @@ Redis、MongoDB、搜索、向量、HBase、配置中心和消息队列工作区
|
||||
|
||||
## 默认端口和连接 URL
|
||||
|
||||
连接 profile 会填入常见默认端口,例如 MySQL `3306`、PostgreSQL `5432`、Redis `6379`、MongoDB `27017`、SQL Server `1433`、Oracle `1521`、Elasticsearch `9200`、RabbitMQ AMQP `5672`。这些只是默认值,可以按部署修改。
|
||||
连接 profile 会填入常见默认端口,例如 MySQL `3306`、PostgreSQL `5432`、Redis `6379`、MongoDB `27017`、SQL Server `1433`、Oracle `1521`、Elasticsearch `9200`、Meilisearch `7700`、RabbitMQ AMQP `5672`。这些只是默认值,可以按部署修改。
|
||||
|
||||
RabbitMQ 等服务可能同时有业务端口和管理 API 端口;自定义 AMQP 端口不能用于推断 Management API 端口。SSH 隧道、HTTP 代理、TLS SNI 和实际拨号目标也应分别确认。
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Desktop file paths belong to the local computer. Docker/Web paths belong to the
|
||||
### Specialized Data Models and Services
|
||||
|
||||
- Redis and MongoDB
|
||||
- Elasticsearch, Easysearch, and Manticore Search
|
||||
- Elasticsearch, Easysearch, Meilisearch, and Manticore Search
|
||||
- Qdrant, Milvus, Weaviate, and ChromaDB
|
||||
- HBase
|
||||
- Pulsar, Kafka, RocketMQ, and RabbitMQ
|
||||
@@ -94,7 +94,7 @@ Both runtimes share most Rust core query, metadata, and safety policy, but are n
|
||||
|
||||
## Default Ports and Connection URLs
|
||||
|
||||
Profiles prefill common defaults such as MySQL `3306`, PostgreSQL `5432`, Redis `6379`, MongoDB `27017`, SQL Server `1433`, Oracle `1521`, Elasticsearch `9200`, and RabbitMQ AMQP `5672`. These are defaults, not requirements.
|
||||
Profiles prefill common defaults such as MySQL `3306`, PostgreSQL `5432`, Redis `6379`, MongoDB `27017`, SQL Server `1433`, Oracle `1521`, Elasticsearch `9200`, Meilisearch `7700`, and RabbitMQ AMQP `5672`. These are defaults, not requirements.
|
||||
|
||||
Services such as RabbitMQ may expose separate business and management ports. A custom AMQP port must not be used to infer the Management API port. SSH tunnels, HTTP proxies, TLS SNI, and physical dial targets should also be verified independently.
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ description: 安装 DBX、创建第一个连接,并了解桌面版、Docker
|
||||
<Step>
|
||||
### 有 URL 时直接粘贴
|
||||
|
||||
DBX 可以解析常见连接 URL,例如 MySQL、PostgreSQL、Redis、MongoDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、DM、GaussDB、openGauss、TDengine 和 Access。保存前请检查解析出的字段。
|
||||
DBX 可以解析常见连接 URL,例如 MySQL、PostgreSQL、Redis、MongoDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、DM、GaussDB、openGauss、TDengine 和 Access。保存前请检查解析出的字段。
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
@@ -163,7 +163,7 @@ Desktop and Web share most database workflows and the Rust core, but their local
|
||||
<Step>
|
||||
### Paste A URL When Available
|
||||
|
||||
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.
|
||||
DBX can parse common connection URLs for engines such as MySQL, PostgreSQL, Redis, MongoDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, DM, GaussDB, openGauss, TDengine, and Access. Review the parsed fields before saving.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
@@ -27,7 +27,7 @@ description: 使用 DBX 侧边栏组织连接、搜索数据库对象、打开
|
||||
| Doris / StarRocks | 可在多 Catalog 时显示 Catalog → 数据库 → 表;只有 `internal` 时保持扁平 |
|
||||
| Redis | 连接 → Redis DB;Key 在专用浏览器中分页展示 |
|
||||
| MongoDB | 连接 → 数据库 → 集合、索引和 GridFS Bucket |
|
||||
| Elasticsearch / Easysearch | 连接 → 索引等文档对象 |
|
||||
| Elasticsearch / Easysearch / Meilisearch | 连接 → 索引等文档对象 |
|
||||
| HBase、向量、消息队列、etcd、ZooKeeper、Nacos | 展示系统对应对象,并打开 [专项工作台](/cn/docs/specialized-workspaces) |
|
||||
|
||||
## 连接分组与批量管理
|
||||
@@ -125,7 +125,7 @@ DBX 不会把所有系统强制映射成关系型树。例如:
|
||||
- SQL Server 可以浏览 Linked Server 和数据库对象
|
||||
- Doris / StarRocks 支持外部 Catalog,外部 Catalog 表使用三段式名称
|
||||
- MongoDB 展示 Collection、索引和 GridFS
|
||||
- Elasticsearch / Easysearch 将索引作为一等对象
|
||||
- Elasticsearch / Easysearch / Meilisearch 将索引作为一等对象
|
||||
- 达梦、Oracle、PostgreSQL 等可以展示更多例程、任务、扩展或管理对象
|
||||
- etcd、Nacos、消息队列和向量数据库从树节点进入专用工作台
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ The actual hierarchy follows the database model:
|
||||
| Doris / StarRocks | Catalog → database → table when multiple catalogs exist; flat when only `internal` exists |
|
||||
| Redis | Connection → Redis DB, with paged keys in the dedicated browser |
|
||||
| MongoDB | Connection → database → collections, indexes, and GridFS buckets |
|
||||
| Elasticsearch / Easysearch | Connection → indexes and other document objects |
|
||||
| Elasticsearch / Easysearch / Meilisearch | Connection → indexes and other document objects |
|
||||
| HBase, vector, message queue, etcd, ZooKeeper, Nacos | System-specific objects that open [Specialized Workspaces](/en/docs/specialized-workspaces) |
|
||||
|
||||
## Connection Groups and Bulk Management
|
||||
@@ -125,7 +125,7 @@ DBX does not force every system into a relational tree. For example:
|
||||
- SQL Server can expose linked servers and database objects
|
||||
- Doris / StarRocks support external catalogs and three-part names for external tables
|
||||
- MongoDB exposes collections, indexes, and GridFS
|
||||
- Elasticsearch / Easysearch model indexes as first-class objects
|
||||
- Elasticsearch / Easysearch / Meilisearch model indexes as first-class objects
|
||||
- Dameng, Oracle, PostgreSQL, and others can expose routines, jobs, extensions, or administration objects
|
||||
- etcd, Nacos, message queues, and vector databases open dedicated workspaces from tree nodes
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ description: 使用 DBX 管理 Redis、MongoDB、搜索与向量数据库、HBas
|
||||
| 类别 | 连接类型 | 主要入口 |
|
||||
| --- | --- | --- |
|
||||
| 键值与文档 | Redis、MongoDB | Key/文档浏览、筛选、编辑、命令或聚合流程 |
|
||||
| 搜索与文档索引 | Elasticsearch、Easysearch、Manticore Search | 索引/文档浏览和对应请求入口 |
|
||||
| 搜索与文档索引 | Elasticsearch、Easysearch、Meilisearch、Manticore Search | 索引/文档浏览和对应请求入口 |
|
||||
| 向量数据库 | Qdrant、Milvus、Weaviate、ChromaDB | Collection 浏览、向量检索、Upsert、删除和请求预览 |
|
||||
| 宽表与列族 | HBase | Namespace、表结构、Column Family 和行数据浏览 |
|
||||
| 消息队列 | Pulsar、Kafka、RocketMQ、RabbitMQ | Topic、订阅/消费者、消息、监控、权限、策略和管理 API |
|
||||
@@ -36,11 +36,12 @@ Redis 和 MongoDB 有独立的详细文档:
|
||||
|
||||
## 搜索与文档索引
|
||||
|
||||
Elasticsearch、Easysearch 和 Manticore Search 使用面向索引与文档的浏览方式。DBX 会根据驱动返回的对象结构展示索引或集合,并提供浏览、查询和查看文档的入口。
|
||||
Elasticsearch、Easysearch、Meilisearch 和 Manticore Search 使用面向索引与文档的浏览方式。DBX 会根据驱动返回的对象结构展示索引或集合,并提供浏览、查询和查看文档的入口。
|
||||
|
||||
使用时需要注意:
|
||||
|
||||
- Elasticsearch 和 Easysearch 的地址通常是 HTTP/HTTPS 服务端点,不是传统数据库 TCP 端口
|
||||
- Elasticsearch、Easysearch 和 Meilisearch 使用 HTTP/HTTPS 服务端点,不是传统数据库 TCP 端口
|
||||
- Meilisearch 使用 DBX 内置的 Rust HTTP 驱动;API Key 填在连接密码字段,默认端口为 `7700`
|
||||
- TLS、代理和认证方式取决于连接类型及服务端配置
|
||||
- 文档查询和写入能力会随驱动、服务端版本和账号权限变化
|
||||
- 结构对比、SQL 文件执行等 SQL 工作流只在对应引擎确实支持时开放
|
||||
|
||||
@@ -12,7 +12,7 @@ In addition to the SQL editor and data grid, DBX opens specialized workspaces fo
|
||||
| Category | Connection types | Main entry points |
|
||||
| --- | --- | --- |
|
||||
| Key/value and document | Redis, MongoDB | Key or document browsing, filtering, editing, commands, and aggregation workflows |
|
||||
| Search and document indexes | Elasticsearch, Easysearch, Manticore Search | Index/document browsing and engine-specific requests |
|
||||
| Search and document indexes | Elasticsearch, Easysearch, Meilisearch, Manticore Search | Index/document browsing and engine-specific requests |
|
||||
| Vector databases | Qdrant, Milvus, Weaviate, ChromaDB | Collection browsing, vector search, upsert, delete, and request preview |
|
||||
| Wide-column and column family | HBase | Namespaces, table structure, column families, and row data |
|
||||
| Message queues | Pulsar, Kafka, RocketMQ, RabbitMQ | Topics, subscriptions or consumers, messages, monitoring, permissions, policies, and admin APIs |
|
||||
@@ -36,11 +36,12 @@ When a connection is read-only, DBX disables or rejects mutations in Redis, Mong
|
||||
|
||||
## Search and Document Indexes
|
||||
|
||||
Elasticsearch, Easysearch, and Manticore Search use index- and document-oriented browsing. DBX displays indexes or collections from driver metadata and provides entry points for browsing, querying, and inspecting documents.
|
||||
Elasticsearch, Easysearch, Meilisearch, and Manticore Search use index- and document-oriented browsing. DBX displays indexes or collections from driver metadata and provides entry points for browsing, querying, and inspecting documents.
|
||||
|
||||
Keep these boundaries in mind:
|
||||
|
||||
- Elasticsearch and Easysearch usually connect to HTTP/HTTPS endpoints rather than traditional database TCP ports
|
||||
- Elasticsearch, Easysearch, and Meilisearch connect to HTTP/HTTPS endpoints rather than traditional database TCP ports
|
||||
- Meilisearch uses DBX's built-in Rust HTTP driver; its API key is stored in the connection password field and the default port is `7700`
|
||||
- TLS, proxying, and authentication depend on the connection type and server configuration
|
||||
- Document queries and mutations vary by driver, server version, and account privileges
|
||||
- SQL workflows such as schema diff or SQL file execution only appear when the engine reliably supports them
|
||||
|
||||
@@ -42,7 +42,7 @@ DBX 的连接选择器按实际工作方式组织数据库和数据系统。不
|
||||
| --- | --- | --- |
|
||||
| 关系型与兼容协议 | MySQL、PostgreSQL、SQLite、Oracle、SQL Server、达梦、openGauss、GaussDB | SQL 编辑器、数据表格、对象树、表结构、Schema 工具 |
|
||||
| 分析型与数据仓库 | ClickHouse、Doris、StarRocks、Databricks、Snowflake、Trino、BigQuery | 查询、结果分析、元数据浏览,以及驱动支持的结构和导出能力 |
|
||||
| 文档、键值与搜索 | MongoDB、Redis、Elasticsearch、Easysearch、HBase、Cassandra | 文档、键、索引或表族等专项浏览器 |
|
||||
| 文档、键值与搜索 | MongoDB、Redis、Elasticsearch、Easysearch、Meilisearch、HBase、Cassandra | 文档、键、索引或表族等专项浏览器 |
|
||||
| 图、向量与 AI 数据 | Neo4j、Qdrant、Milvus、Weaviate、ChromaDB | 图数据库连接,以及向量集合浏览、检索和写入入口 |
|
||||
| 时序系统 | QuestDB、TDengine、IoTDB、InfluxDB | 时序查询与系统对应的连接、对象和数据视图 |
|
||||
| 消息队列 | Pulsar、Kafka、RocketMQ、RabbitMQ | Topic、订阅/消费者、消息、监控、权限或策略等能力 |
|
||||
|
||||
@@ -42,7 +42,7 @@ The connection picker groups databases and data systems by how they are actually
|
||||
| --- | --- | --- |
|
||||
| Relational and compatible protocols | MySQL, PostgreSQL, SQLite, Oracle, SQL Server, Dameng, openGauss, GaussDB | SQL editor, data grid, object tree, table structure, and schema tools |
|
||||
| Analytics and warehouses | ClickHouse, Doris, StarRocks, Databricks, Snowflake, Trino, BigQuery | Queries, result analysis, metadata, and driver-supported schema and export tools |
|
||||
| Document, key/value, and search | MongoDB, Redis, Elasticsearch, Easysearch, HBase, Cassandra | Specialized document, key, index, or column-family browsers |
|
||||
| Document, key/value, and search | MongoDB, Redis, Elasticsearch, Easysearch, Meilisearch, HBase, Cassandra | Specialized document, key, index, or column-family browsers |
|
||||
| Graph, vector, and AI data | Neo4j, Qdrant, Milvus, Weaviate, ChromaDB | Graph connections plus vector collection browsing, search, and mutation flows |
|
||||
| Time series | QuestDB, TDengine, IoTDB, InfluxDB | Time-series queries and system-specific connection, object, and data views |
|
||||
| Message queues | Pulsar, Kafka, RocketMQ, RabbitMQ | Topics, subscriptions or consumers, messages, monitoring, permissions, and policies |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Meilisearch">
|
||||
<defs>
|
||||
<linearGradient id="m" x1="8" y1="56" x2="56" y2="8" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF5CAA"/>
|
||||
<stop offset="1" stop-color="#FFB03A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#m)" d="M10 47.5 18.5 17a5 5 0 0 1 9.2-1.2L32 23l4.3-7.2a5 5 0 0 1 9.2 1.2L54 47.5a4.5 4.5 0 0 1-8.6 2.6L40.2 31l-4.3 7.1a4.5 4.5 0 0 1-7.8 0L23.8 31l-5.2 19.1a4.5 4.5 0 0 1-8.6-2.6Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 519 B |
@@ -1001,6 +1001,18 @@ async fn test_connection_with_info_inner(
|
||||
.await
|
||||
.map(|_| "Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::Meilisearch => {
|
||||
let client = db::meilisearch_driver::MeilisearchClient::new(
|
||||
&url,
|
||||
Some(&config.password),
|
||||
config.ssl,
|
||||
config.url_params.as_deref(),
|
||||
connect_timeout,
|
||||
)?;
|
||||
db::meilisearch_driver::test_connection(&client, connect_timeout)
|
||||
.await
|
||||
.map(|_| "Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::Hbase => {
|
||||
let client = db::hbase_driver::HBaseClient::new(
|
||||
&url,
|
||||
@@ -1371,6 +1383,17 @@ pub async fn connect_db(
|
||||
db::easysearch_driver::test_connection(&mut client, connect_timeout).await?;
|
||||
PoolKind::Easysearch(client)
|
||||
}
|
||||
DatabaseType::Meilisearch => {
|
||||
let client = db::meilisearch_driver::MeilisearchClient::new(
|
||||
&url,
|
||||
Some(&db_config.password),
|
||||
db_config.ssl,
|
||||
db_config.url_params.as_deref(),
|
||||
connect_timeout,
|
||||
)?;
|
||||
db::meilisearch_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Meilisearch(client)
|
||||
}
|
||||
DatabaseType::Hbase => {
|
||||
let client = db::hbase_driver::HBaseClient::new(
|
||||
&url,
|
||||
|
||||
@@ -177,6 +177,27 @@ pub async fn document_delete_document(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn document_save_meilisearch_batch(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
collection: String,
|
||||
updates: Vec<dbx_core::db::meilisearch_driver::MeilisearchDocumentUpdate>,
|
||||
delete_ids: Vec<String>,
|
||||
inserts: Vec<String>,
|
||||
) -> Result<u64, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Save").await?;
|
||||
dbx_core::document_ops::save_meilisearch_document_batch_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
&collection,
|
||||
&updates,
|
||||
&delete_ids,
|
||||
&inserts,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn document_list_gridfs_buckets(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
||||
@@ -2010,6 +2010,7 @@ pub fn run() {
|
||||
commands::mongo_cmd::mongo_update_document,
|
||||
commands::mongo_cmd::mongo_update_documents,
|
||||
commands::document_cmd::document_delete_document,
|
||||
commands::document_cmd::document_save_meilisearch_batch,
|
||||
commands::hbase_cmd::hbase_get_table_schema,
|
||||
commands::hbase_cmd::hbase_scan_rows,
|
||||
commands::hbase_cmd::hbase_get_row,
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "apps/desktop/src"),
|
||||
"@dbx-app/mongo-shell": path.resolve(__dirname, "packages/mongo-shell/src/index.ts"),
|
||||
"@": path.resolve(import.meta.dirname, "apps/desktop/src"),
|
||||
"@dbx-app/mongo-shell": path.resolve(import.meta.dirname, "packages/mongo-shell/src/index.ts"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user