mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
Merge branch 'main' into dev/fnos-app-package
This commit is contained in:
@@ -145,6 +145,22 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
|
||||
assert.ok(win7.includes("--timings"));
|
||||
assert.ok(win7.includes("name: DBX-win7-cargo-timings"));
|
||||
assert.ok(win7.includes("path: target/cargo-timings/"));
|
||||
assert.ok(win7.includes("name: Measure dbx library command registry expansion"));
|
||||
assert.ok(win7.includes("if: vars.WIN7_REGISTRY_AB == 'true'"));
|
||||
assert.ok(win7.includes("--package dbx"));
|
||||
assert.ok(win7.includes("--lib"));
|
||||
assert.ok(win7.includes('$env:RUSTC_WRAPPER = ""'));
|
||||
assert.ok(win7.includes('Measure-Registry "full" $fullCommandCount'));
|
||||
assert.ok(win7.includes('Measure-Registry "half" $keptCommandCount'));
|
||||
assert.ok(win7.includes('"-Zdump-mono-stats=$outputDir"'));
|
||||
assert.ok(win7.includes('"-Zdump-mono-stats-format=json"'));
|
||||
assert.ok(win7.includes("git restore --source=HEAD -- $sourcePath"));
|
||||
assert.ok(win7.includes("name: DBX-win7-dbx-lib-command-registry-ab"));
|
||||
assert.ok(win7.includes("if: always() && vars.WIN7_REGISTRY_AB == 'true'"));
|
||||
assert.ok(win7.includes("path: ${{ runner.temp }}/dbx-lib-command-registry-ab/"));
|
||||
assert.ok(win7.includes("retention-days: 3"));
|
||||
assert.ok(win7.indexOf("name: Build DBX for Windows 7") < win7.indexOf("name: Measure dbx library command registry expansion"));
|
||||
assert.ok(win7.indexOf("name: Measure dbx library command registry expansion") < win7.indexOf("name: Upload dbx library command registry A/B"));
|
||||
assert.doesNotMatch(win7, /^\s+(?:CC|CXX):/m);
|
||||
});
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ export function inferAreaLabels(changedFiles) {
|
||||
if (has((file) => file.startsWith("crates/dbx-cli/")
|
||||
|| file.startsWith("packages/cli-")
|
||||
|| file.startsWith("packages/cli/")
|
||||
|| file.startsWith("skills/dbx/")
|
||||
|| file.startsWith("examples/cli/"))) {
|
||||
labels.add("area/cli");
|
||||
}
|
||||
|
||||
@@ -53,6 +53,16 @@ test("labels documentation-only changes without a conventional title", () => {
|
||||
assert.deepEqual(result.labels, ["area/docs", "documentation"]);
|
||||
});
|
||||
|
||||
test("labels the bundled DBX skill as CLI work", () => {
|
||||
const result = evaluatePullRequestLabels({
|
||||
title: "feat(cli): improve the bundled agent skill",
|
||||
changedFiles: ["skills/dbx/SKILL.md", "skills/dbx/references/commands.md"],
|
||||
knownDatabaseTypes,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.labels, ["area/cli", "enhancement"]);
|
||||
});
|
||||
|
||||
test("maps agent and dialect paths to existing database types", () => {
|
||||
assert.deepEqual(
|
||||
inferDatabaseTypes([
|
||||
|
||||
@@ -577,6 +577,122 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Measure dbx library command registry expansion
|
||||
if: vars.WIN7_REGISTRY_AB == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$resultRoot = Join-Path $env:RUNNER_TEMP "dbx-lib-command-registry-ab"
|
||||
$sourcePath = "src-tauri/src/lib.rs"
|
||||
New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
|
||||
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
|
||||
|
||||
$results = @{}
|
||||
function Measure-Registry([string] $name, [int] $commandCount) {
|
||||
$outputDir = Join-Path $resultRoot $name
|
||||
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
|
||||
$timer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
cargo rustc `
|
||||
--locked `
|
||||
--package dbx `
|
||||
--release `
|
||||
--lib `
|
||||
--features custom-protocol `
|
||||
--target x86_64-win7-windows-msvc `
|
||||
-Z build-std=std,panic_abort `
|
||||
-- `
|
||||
"-Zdump-mono-stats=$outputDir" `
|
||||
"-Zdump-mono-stats-format=json"
|
||||
$exitCode = $LASTEXITCODE
|
||||
$timer.Stop()
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$name command registry compile failed with exit code $exitCode."
|
||||
}
|
||||
|
||||
$monoFile = Get-ChildItem $outputDir -File -Filter "*.mono_items.json" |
|
||||
Select-Object -First 1
|
||||
if (!$monoFile) {
|
||||
throw "$name command registry compile did not create mono statistics."
|
||||
}
|
||||
$monoItems = Get-Content $monoFile.FullName -Raw | ConvertFrom-Json
|
||||
$results[$name] = @{
|
||||
Commands = $commandCount
|
||||
Definitions = $monoItems.Count
|
||||
Seconds = [math]::Round($timer.Elapsed.TotalSeconds, 2)
|
||||
TotalEstimate = ($monoItems | Measure-Object -Property total_estimate -Sum).Sum
|
||||
}
|
||||
}
|
||||
|
||||
$savedWrapper = $env:RUSTC_WRAPPER
|
||||
try {
|
||||
$env:RUSTC_WRAPPER = ""
|
||||
$sourceLines = Get-Content $sourcePath
|
||||
$insideHandler = $false
|
||||
$fullCommandCount = 0
|
||||
foreach ($line in $sourceLines) {
|
||||
if ($line -match 'tauri::generate_handler!\[') {
|
||||
$insideHandler = $true
|
||||
continue
|
||||
}
|
||||
if ($insideHandler -and $line -match '^\s*\]\)\)') {
|
||||
break
|
||||
}
|
||||
if ($insideHandler -and $line -match '^\s+commands::') {
|
||||
$fullCommandCount++
|
||||
}
|
||||
}
|
||||
Measure-Registry "full" $fullCommandCount
|
||||
|
||||
$insideHandler = $false
|
||||
$handlerCommandCount = 0
|
||||
$keptCommandCount = 0
|
||||
$reducedLines = foreach ($line in $sourceLines) {
|
||||
if ($line -match 'tauri::generate_handler!\[') {
|
||||
$insideHandler = $true
|
||||
$line
|
||||
continue
|
||||
}
|
||||
if ($insideHandler -and $line -match '^\s*\]\)\)') {
|
||||
$insideHandler = $false
|
||||
$line
|
||||
continue
|
||||
}
|
||||
if ($insideHandler -and $line -match '^\s+commands::') {
|
||||
$handlerCommandCount++
|
||||
if ($handlerCommandCount % 2 -eq 0) {
|
||||
continue
|
||||
}
|
||||
$keptCommandCount++
|
||||
}
|
||||
$line
|
||||
}
|
||||
if ($handlerCommandCount -ne $fullCommandCount -or $keptCommandCount -eq $fullCommandCount) {
|
||||
throw "Could not create the reduced command registry."
|
||||
}
|
||||
$reducedLines | Set-Content $sourcePath -Encoding utf8NoBOM
|
||||
Measure-Registry "half" $keptCommandCount
|
||||
} finally {
|
||||
$env:RUSTC_WRAPPER = $savedWrapper
|
||||
git restore --source=HEAD -- $sourcePath
|
||||
}
|
||||
|
||||
@(
|
||||
"### dbx library command registry A/B"
|
||||
""
|
||||
"| Registry | Commands | Definitions | Estimated cost | Seconds |"
|
||||
"| --- | ---: | ---: | ---: | ---: |"
|
||||
"| Full | $($results['full'].Commands) | $($results['full'].Definitions) | $($results['full'].TotalEstimate) | $($results['full'].Seconds) |"
|
||||
"| Half | $($results['half'].Commands) | $($results['half'].Definitions) | $($results['half'].TotalEstimate) | $($results['half'].Seconds) |"
|
||||
) | Add-Content $env:GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload dbx library command registry A/B
|
||||
if: always() && vars.WIN7_REGISTRY_AB == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: DBX-win7-dbx-lib-command-registry-ab
|
||||
path: ${{ runner.temp }}/dbx-lib-command-registry-ab/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
duckdb-windows-driver:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.duckdb_windows == 'true'
|
||||
@@ -973,6 +1089,7 @@ jobs:
|
||||
- 'packages/mcp-server/**'
|
||||
- 'crates/dbx-cli/**'
|
||||
- 'crates/dbx-mcp/**'
|
||||
- 'skills/dbx/**'
|
||||
- 'scripts/verify-package-install.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
Generated
+17
-4
@@ -2047,7 +2047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.25"
|
||||
version = "0.6.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2057,6 +2057,7 @@ dependencies = [
|
||||
"csv",
|
||||
"dbx-core",
|
||||
"dbx-mcp",
|
||||
"dbx-tauri-consul",
|
||||
"deadpool-postgres",
|
||||
"font-kit",
|
||||
"futures",
|
||||
@@ -2128,14 +2129,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.99"
|
||||
version = "0.4.100"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
"dbx-mcp",
|
||||
"dirs",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
@@ -2460,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.99"
|
||||
version = "0.4.100"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -2607,6 +2610,16 @@ dependencies = [
|
||||
"sqlparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-tauri-consul"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-types"
|
||||
version = "0.1.0"
|
||||
@@ -2621,7 +2634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.25"
|
||||
version = "0.6.26"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-driver-agent", "crates/dbx-driver-support", "crates/dbx-driver-elasticsearch", "crates/dbx-driver-mongodb", "crates/dbx-driver-mysql", "crates/dbx-driver-postgres", "crates/dbx-driver-redis", "crates/dbx-driver-sqlserver", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-sql-core", "crates/dbx-sql-data", "crates/dbx-sql-dialect", "crates/dbx-sql-schema", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-driver-agent", "crates/dbx-driver-support", "crates/dbx-driver-elasticsearch", "crates/dbx-driver-mongodb", "crates/dbx-driver-mysql", "crates/dbx-driver-postgres", "crates/dbx-driver-redis", "crates/dbx-driver-sqlserver", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-sql-core", "crates/dbx-sql-data", "crates/dbx-sql-dialect", "crates/dbx-sql-schema", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker", "crates/dbx-tauri-consul"]
|
||||
exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/webview2-com-sys", "vendor/wry"]
|
||||
|
||||
[patch.crates-io]
|
||||
|
||||
@@ -335,10 +335,13 @@ DBX also provides a dedicated CLI package for terminal, script, and Codex workfl
|
||||
npm install -g @dbx-app/cli
|
||||
# or via Homebrew
|
||||
brew tap t8y2/tap && brew install dbx-cli
|
||||
dbx agent setup
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
The CLI includes the official DBX Agent Skill. `dbx agent setup` installs or updates it offline under `~/.agents/skills/dbx`, so shell-capable AI agents know how to use DBX CLI safely.
|
||||
|
||||
See the [MCP server README](packages/mcp-server/README.md) and [CLI README](packages/cli/README.md) for details.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -333,10 +333,13 @@ DBX 也提供独立 CLI 包,适合终端、脚本和 Codex 工作流:
|
||||
npm install -g @dbx-app/cli
|
||||
# 或通过 Homebrew
|
||||
brew tap t8y2/tap && brew install dbx-cli
|
||||
dbx agent setup
|
||||
dbx connections list --json
|
||||
dbx query local "select 1" --json
|
||||
```
|
||||
|
||||
CLI 已内置官方 DBX Agent Skill;`dbx agent setup` 会离线安装或更新到 `~/.agents/skills/dbx`,让支持 Shell 的 AI Agent 知道如何安全调用 DBX CLI。
|
||||
|
||||
详见 [MCP Server 说明](packages/mcp-server/README.md) 和 [CLI 说明](packages/cli/README.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -42,7 +43,7 @@ func TestCassandraIntegration(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.close()
|
||||
t.Cleanup(runtime.close)
|
||||
server := newServer(runtime, connection)
|
||||
if err := server.validateConnection(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -54,7 +55,9 @@ func TestCassandraIntegration(t *testing.T) {
|
||||
pagedTable := "paged_rows"
|
||||
mustCQL(t, server, "CREATE KEYSPACE "+quoteCQLIdentifier(keyspace)+" WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", "")
|
||||
t.Cleanup(func() {
|
||||
_, _ = server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)})
|
||||
if _, err := server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)}); err != nil {
|
||||
t.Errorf("drop integration keyspace %s: %v", keyspace, err)
|
||||
}
|
||||
})
|
||||
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, table)+" ("+
|
||||
"id int PRIMARY KEY, txt text, flag boolean, amount decimal, payload blob, created timestamp, address inet, "+
|
||||
@@ -139,6 +142,187 @@ func TestCassandraIntegration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCassandraMaterializedViewDDLIntegration(t *testing.T) {
|
||||
host := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_HOST"))
|
||||
if host == "" {
|
||||
t.Skip("Cassandra integration environment is not configured")
|
||||
}
|
||||
port := 9042
|
||||
if rawPort := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_PORT")); rawPort != "" {
|
||||
parsedPort, err := strconv.Atoi(rawPort)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port = parsedPort
|
||||
}
|
||||
ssl, err := strconv.ParseBool(envDefault("CASSANDRA_TEST_SSL", "false"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection := connectParams{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: os.Getenv("CASSANDRA_TEST_USERNAME"),
|
||||
Password: os.Getenv("CASSANDRA_TEST_PASSWORD"),
|
||||
URLParams: os.Getenv("CASSANDRA_TEST_URL_PARAMS"),
|
||||
SSL: ssl,
|
||||
CACertPath: os.Getenv("CASSANDRA_TEST_CA_CERT_PATH"),
|
||||
ClientCertPath: os.Getenv("CASSANDRA_TEST_CLIENT_CERT_PATH"),
|
||||
ClientKeyPath: os.Getenv("CASSANDRA_TEST_CLIENT_KEY_PATH"),
|
||||
}
|
||||
runtime, err := newConnectionRuntime(connection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(runtime.close)
|
||||
server := newServer(runtime, connection)
|
||||
if err := server.validateConnection(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connectionInfo, err := server.connectionInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("Cassandra runtime version: %v", connectionInfo["version"])
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
keyspace := "dbx_issue_7475_regression_" + suffix
|
||||
baseTable := "BaseEvents"
|
||||
view := "EventsByCategory"
|
||||
allColumnsView := "AllEventsByCategory"
|
||||
mustCQL(t, server, "CREATE KEYSPACE "+quoteCQLIdentifier(keyspace)+" WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", "")
|
||||
t.Cleanup(func() {
|
||||
if _, err := server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)}); err != nil {
|
||||
t.Errorf("drop integration keyspace %s: %v", keyspace, err)
|
||||
}
|
||||
})
|
||||
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, baseTable)+" ("+
|
||||
`"Tenant Id" text, "Bucket" int, "Event Time" timestamp, "Category" text, "Payload" text, `+
|
||||
`PRIMARY KEY ("Tenant Id", "Event Time", "Bucket")) `+
|
||||
`WITH CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`, keyspace)
|
||||
mustCQL(t, server, "CREATE MATERIALIZED VIEW "+qualifiedCQLName(keyspace, view)+" AS "+
|
||||
`SELECT "Tenant Id", "Bucket", "Event Time", "Category" FROM `+qualifiedCQLName(keyspace, baseTable)+" "+
|
||||
`WHERE "Tenant Id" IS NOT NULL AND "Event Time" IS NOT NULL AND "Bucket" IS NOT NULL AND "Category" IS NOT NULL `+
|
||||
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket") `+
|
||||
`WITH CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC) AND comment = 'issue 7475 round trip' AND gc_grace_seconds = 86401`, keyspace)
|
||||
mustCQL(t, server, "CREATE MATERIALIZED VIEW "+qualifiedCQLName(keyspace, allColumnsView)+" AS "+
|
||||
`SELECT * FROM `+qualifiedCQLName(keyspace, baseTable)+" "+
|
||||
`WHERE "Tenant Id" IS NOT NULL AND "Event Time" IS NOT NULL AND "Bucket" IS NOT NULL AND "Category" IS NOT NULL `+
|
||||
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket") `+
|
||||
`WITH CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`, keyspace)
|
||||
|
||||
ddl, err := server.getTableDDL(keyspace, view)
|
||||
if err != nil {
|
||||
t.Fatalf("materialized view DDL failed: %v", err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
"CREATE MATERIALIZED VIEW",
|
||||
"FROM " + keyspace + "." + quoteCQLIdentifier(baseTable),
|
||||
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket")`,
|
||||
`CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`,
|
||||
"comment = 'issue 7475 round trip'",
|
||||
"gc_grace_seconds = 86401",
|
||||
} {
|
||||
if !strings.Contains(ddl, fragment) {
|
||||
t.Fatalf("materialized view DDL omitted %q:\n%s", fragment, ddl)
|
||||
}
|
||||
}
|
||||
projection := materializedViewProjection(t, ddl)
|
||||
wantProjection := []string{`"Bucket"`, `"Category"`, `"Event Time"`, `"Tenant Id"`}
|
||||
sort.Strings(projection)
|
||||
sort.Strings(wantProjection)
|
||||
if strings.Join(projection, ",") != strings.Join(wantProjection, ",") || strings.Contains(ddl, `"Payload"`) || strings.Contains(ddl, "SELECT *") {
|
||||
t.Fatalf("materialized view DDL lost its selected-column subset:\n%s", ddl)
|
||||
}
|
||||
metadataSession, err := runtime.sessionFor("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyCatalog, err := legacyMaterializedViewCatalog(metadataSession, keyspace, view)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy materialized view catalog failed: %v", err)
|
||||
}
|
||||
legacyDDL, err := materializedViewDDLFromCatalog(keyspace, view, legacyCatalog)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy materialized view DDL failed: %v", err)
|
||||
}
|
||||
legacyProjection := materializedViewProjection(t, legacyDDL)
|
||||
sort.Strings(legacyProjection)
|
||||
if strings.Join(legacyProjection, ",") != strings.Join(wantProjection, ",") || strings.Contains(legacyDDL, `"Payload"`) || strings.Contains(legacyDDL, "SELECT *") {
|
||||
t.Fatalf("legacy materialized view DDL lost its selected-column subset:\n%s", legacyDDL)
|
||||
}
|
||||
allColumnsDDL, err := server.getTableDDL(keyspace, allColumnsView)
|
||||
if err != nil || !strings.Contains(allColumnsDDL, "SELECT *") {
|
||||
t.Fatalf("materialized view SELECT * was not preserved: ddl=%q err=%v", allColumnsDDL, err)
|
||||
}
|
||||
|
||||
result, _, err := server.dispatch("get_object_source", map[string]json.RawMessage{
|
||||
"schema": json.RawMessage(strconv.Quote(keyspace)),
|
||||
"name": json.RawMessage(strconv.Quote(view)),
|
||||
"object_type": json.RawMessage(`"MATERIALIZED_VIEW"`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("materialized view object-source RPC failed: %v", err)
|
||||
}
|
||||
source, ok := result.(objectSource)
|
||||
if !ok || source.Source != ddl || source.Name != view || source.ObjectType != "MATERIALIZED_VIEW" {
|
||||
t.Fatalf("unexpected materialized view object-source response: %#v", result)
|
||||
}
|
||||
|
||||
tableDDL, err := server.getTableDDL(keyspace, baseTable)
|
||||
if err != nil || !strings.HasPrefix(tableDDL, "CREATE TABLE ") || strings.Contains(tableDDL, "CREATE MATERIALIZED VIEW") {
|
||||
t.Fatalf("ordinary table DDL changed: ddl=%q err=%v", tableDDL, err)
|
||||
}
|
||||
if _, err := server.getTableDDL(keyspace, "MissingObject"); err == nil {
|
||||
t.Fatal("missing schema object unexpectedly returned DDL")
|
||||
}
|
||||
if _, err := server.getMaterializedViewDDL(keyspace, "MissingObject"); err == nil {
|
||||
t.Fatal("missing materialized view unexpectedly returned DDL")
|
||||
}
|
||||
|
||||
mustCQL(t, server, "DROP MATERIALIZED VIEW "+qualifiedCQLName(keyspace, view), keyspace)
|
||||
mustCQL(t, server, ddl, keyspace)
|
||||
recreatedDDL, err := server.getTableDDL(keyspace, view)
|
||||
if err != nil {
|
||||
t.Fatalf("materialized view DDL after recreation failed: %v", err)
|
||||
}
|
||||
if recreatedDDL != ddl {
|
||||
t.Fatalf("materialized view changed after DDL round trip:\nbefore:\n%s\nafter:\n%s", ddl, recreatedDDL)
|
||||
}
|
||||
mustCQL(t, server, "DROP MATERIALIZED VIEW "+qualifiedCQLName(keyspace, view), keyspace)
|
||||
mustCQL(t, server, legacyDDL, keyspace)
|
||||
legacyRecreatedDDL, err := server.getTableDDL(keyspace, view)
|
||||
if err != nil {
|
||||
t.Fatalf("materialized view DDL after legacy recreation failed: %v", err)
|
||||
}
|
||||
if legacyRecreatedDDL != ddl {
|
||||
t.Fatalf("legacy materialized view DDL changed the definition:\nbefore:\n%s\nafter:\n%s", ddl, legacyRecreatedDDL)
|
||||
}
|
||||
}
|
||||
|
||||
func materializedViewProjection(t *testing.T, ddl string) []string {
|
||||
t.Helper()
|
||||
upper := strings.ToUpper(ddl)
|
||||
selectIndex := strings.Index(upper, "SELECT ")
|
||||
if selectIndex < 0 {
|
||||
t.Fatalf("DDL has no SELECT clause:\n%s", ddl)
|
||||
}
|
||||
projectionStart := selectIndex + len("SELECT ")
|
||||
fromOffset := strings.Index(upper[projectionStart:], "FROM ")
|
||||
if fromOffset < 0 {
|
||||
t.Fatalf("DDL has no FROM clause:\n%s", ddl)
|
||||
}
|
||||
projection := strings.TrimSpace(ddl[projectionStart : projectionStart+fromOffset])
|
||||
if projection == "" {
|
||||
t.Fatalf("DDL has an empty projection:\n%s", ddl)
|
||||
}
|
||||
columns := strings.Split(projection, ",")
|
||||
for index := range columns {
|
||||
columns[index] = strings.TrimSpace(columns[index])
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func envDefault(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
|
||||
@@ -365,7 +365,12 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
|
||||
result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
case "get_object_source":
|
||||
return nil, false, errors.New("object source is not supported by Cassandra")
|
||||
result, err := s.getObjectSource(
|
||||
firstNonEmpty(stringParam(params, "schema"), stringParam(params, "database"), s.defaultKeyspace()),
|
||||
firstNonEmpty(stringParam(params, "name"), stringParam(params, "table")),
|
||||
stringParam(params, "object_type"),
|
||||
)
|
||||
return result, false, err
|
||||
case "get_table_ddl":
|
||||
result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
const maxLegacyMaterializedViewColumns = 4096
|
||||
|
||||
const cassandraMaterializedViewObjectType = "MATERIALIZED_VIEW"
|
||||
|
||||
var legacyMaterializedViewOptionNames = []string{
|
||||
"additional_write_policy",
|
||||
"bloom_filter_fp_chance",
|
||||
"caching",
|
||||
"cdc",
|
||||
"comment",
|
||||
"compaction",
|
||||
"compression",
|
||||
"crc_check_chance",
|
||||
"dclocal_read_repair_chance",
|
||||
"gc_grace_seconds",
|
||||
"max_index_interval",
|
||||
"memtable",
|
||||
"memtable_flush_period_in_ms",
|
||||
"min_index_interval",
|
||||
"read_repair",
|
||||
"read_repair_chance",
|
||||
"speculative_retry",
|
||||
}
|
||||
|
||||
type objectSource struct {
|
||||
Name string `json:"name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
Schema *string `json:"schema"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type materializedViewColumn struct {
|
||||
Name string
|
||||
Kind string
|
||||
Position int
|
||||
ClusteringOrder string
|
||||
}
|
||||
|
||||
type materializedViewCatalog struct {
|
||||
BaseTable string
|
||||
IncludeAllColumns bool
|
||||
WhereClause string
|
||||
Columns []materializedViewColumn
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
func (s *server) getObjectSource(schema, name, objectType string) (objectSource, error) {
|
||||
return materializedViewObjectSource(schema, name, objectType, func() (string, error) {
|
||||
return s.getMaterializedViewDDL(schema, name)
|
||||
})
|
||||
}
|
||||
|
||||
func materializedViewObjectSource(schema, name, objectType string, loadDDL func() (string, error)) (objectSource, error) {
|
||||
if normalizeCassandraObjectType(objectType) != cassandraMaterializedViewObjectType {
|
||||
return objectSource{}, errors.New("object source is not supported by Cassandra")
|
||||
}
|
||||
source, err := loadDDL()
|
||||
if err != nil {
|
||||
return objectSource{}, err
|
||||
}
|
||||
return objectSource{
|
||||
Name: name,
|
||||
ObjectType: cassandraMaterializedViewObjectType,
|
||||
Schema: optionalString(schema),
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeCassandraObjectType(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
value = strings.ReplaceAll(value, "-", "_")
|
||||
return strings.ReplaceAll(value, " ", "_")
|
||||
}
|
||||
|
||||
func (s *server) getMaterializedViewDDL(schema, view string) (string, error) {
|
||||
session, err := s.runtime.sessionFor("")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return materializedViewDDLWithFallback(
|
||||
func() (string, error) {
|
||||
return describeMaterializedViewDDL(session, schema, view)
|
||||
},
|
||||
func() (string, error) {
|
||||
catalog, err := legacyMaterializedViewCatalog(session, schema, view)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return materializedViewDDLFromCatalog(schema, view, catalog)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func materializedViewDDLWithFallback(describe, legacy func() (string, error)) (string, error) {
|
||||
ddl, err := describe()
|
||||
if err == nil {
|
||||
if strings.TrimSpace(ddl) == "" {
|
||||
return "", errors.New("Cassandra returned an empty materialized view definition")
|
||||
}
|
||||
return ddl, nil
|
||||
}
|
||||
if !isUnsupportedDescribeSyntax(err) {
|
||||
return "", err
|
||||
}
|
||||
return legacy()
|
||||
}
|
||||
|
||||
func isUnsupportedDescribeSyntax(err error) bool {
|
||||
var requestError gocql.RequestError
|
||||
return errors.As(err, &requestError) && requestError.Code() == gocql.ErrCodeSyntax
|
||||
}
|
||||
|
||||
func describeMaterializedViewDDL(session *gocql.Session, schema, view string) (string, error) {
|
||||
statement := "DESCRIBE MATERIALIZED VIEW " + qualifiedCQLIdentifier(schema, view)
|
||||
var describedSchema, objectType, describedName, ddl string
|
||||
if err := session.Query(statement).Scan(&describedSchema, &objectType, &describedName, &ddl); err != nil {
|
||||
return "", fmt.Errorf("describe Cassandra materialized view %s: %w", qualifiedCQLIdentifier(schema, view), err)
|
||||
}
|
||||
if normalizeCassandraObjectType(objectType) != cassandraMaterializedViewObjectType {
|
||||
return "", fmt.Errorf("Cassandra DESCRIBE returned object type %q for materialized view %s", objectType, qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
if strings.TrimSpace(ddl) == "" {
|
||||
return "", fmt.Errorf("Cassandra returned an empty materialized view definition for %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
return ddl, nil
|
||||
}
|
||||
|
||||
func legacyMaterializedViewCatalog(session *gocql.Session, schema, view string) (materializedViewCatalog, error) {
|
||||
row := map[string]any{}
|
||||
err := session.Query(
|
||||
"SELECT * FROM system_schema.views WHERE keyspace_name = ? AND view_name = ?",
|
||||
schema,
|
||||
view,
|
||||
).MapScan(row)
|
||||
if errors.Is(err, gocql.ErrNotFound) {
|
||||
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view not found: %s.%s", schema, view)
|
||||
}
|
||||
if err != nil {
|
||||
return materializedViewCatalog{}, fmt.Errorf("read Cassandra materialized view catalog for %s: %w", qualifiedCQLIdentifier(schema, view), err)
|
||||
}
|
||||
|
||||
baseTable, ok := row["base_table_name"].(string)
|
||||
if !ok || strings.TrimSpace(baseTable) == "" {
|
||||
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view catalog has no base table for %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
includeAllColumns, ok := row["include_all_columns"].(bool)
|
||||
if !ok {
|
||||
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view catalog has no projection mode for %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
whereClause, ok := row["where_clause"].(string)
|
||||
if !ok || strings.TrimSpace(whereClause) == "" {
|
||||
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view catalog has no WHERE clause for %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
|
||||
options, err := legacyMaterializedViewOptions(row)
|
||||
if err != nil {
|
||||
return materializedViewCatalog{}, fmt.Errorf("read Cassandra materialized view options for %s: %w", qualifiedCQLIdentifier(schema, view), err)
|
||||
}
|
||||
columns, err := legacyMaterializedViewColumns(session, schema, view)
|
||||
if err != nil {
|
||||
return materializedViewCatalog{}, err
|
||||
}
|
||||
return materializedViewCatalog{
|
||||
BaseTable: baseTable,
|
||||
IncludeAllColumns: includeAllColumns,
|
||||
WhereClause: whereClause,
|
||||
Columns: columns,
|
||||
Options: options,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func legacyMaterializedViewOptions(row map[string]any) (map[string]any, error) {
|
||||
metadataColumns := map[string]bool{
|
||||
"base_table_id": true,
|
||||
"base_table_name": true,
|
||||
"id": true,
|
||||
"include_all_columns": true,
|
||||
"keyspace_name": true,
|
||||
"view_name": true,
|
||||
"where_clause": true,
|
||||
}
|
||||
knownOptions := make(map[string]bool, len(legacyMaterializedViewOptionNames)+1)
|
||||
for _, name := range legacyMaterializedViewOptionNames {
|
||||
knownOptions[name] = true
|
||||
}
|
||||
knownOptions["default_time_to_live"] = true
|
||||
knownOptions["extensions"] = true
|
||||
|
||||
for name, value := range row {
|
||||
if value != nil && !metadataColumns[name] && !knownOptions[name] {
|
||||
return nil, fmt.Errorf("unsupported system_schema.views column %q", name)
|
||||
}
|
||||
}
|
||||
if value, exists := row["default_time_to_live"]; exists && value != nil && fmt.Sprint(value) != "0" {
|
||||
return nil, fmt.Errorf("unsupported non-zero default_time_to_live %v", value)
|
||||
}
|
||||
if value, exists := row["extensions"]; exists && value != nil && !isEmptyCQLCollection(value) {
|
||||
return nil, errors.New("non-empty materialized view extensions cannot be reproduced safely")
|
||||
}
|
||||
|
||||
options := make(map[string]any, len(legacyMaterializedViewOptionNames))
|
||||
_, hasModernReadRepair := row["read_repair"]
|
||||
for _, name := range legacyMaterializedViewOptionNames {
|
||||
if value, exists := row[name]; exists && value != nil {
|
||||
if hasModernReadRepair && (name == "dclocal_read_repair_chance" || name == "read_repair_chance") {
|
||||
continue
|
||||
}
|
||||
if name == "memtable" && strings.TrimSpace(fmt.Sprint(value)) == "" {
|
||||
continue
|
||||
}
|
||||
options[name] = value
|
||||
}
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func legacyMaterializedViewColumns(session *gocql.Session, schema, view string) ([]materializedViewColumn, error) {
|
||||
iter := session.Query(
|
||||
"SELECT column_name, kind, position, clustering_order FROM system_schema.columns WHERE keyspace_name = ? AND table_name = ?",
|
||||
schema,
|
||||
view,
|
||||
).PageSize(512).Iter()
|
||||
columns := make([]materializedViewColumn, 0)
|
||||
var column materializedViewColumn
|
||||
for iter.Scan(&column.Name, &column.Kind, &column.Position, &column.ClusteringOrder) {
|
||||
if len(columns) >= maxLegacyMaterializedViewColumns {
|
||||
_ = iter.Close()
|
||||
return nil, fmt.Errorf("Cassandra materialized view %s has more than %d columns", qualifiedCQLIdentifier(schema, view), maxLegacyMaterializedViewColumns)
|
||||
}
|
||||
columns = append(columns, column)
|
||||
column = materializedViewColumn{}
|
||||
}
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("read Cassandra materialized view columns for %s: %w", qualifiedCQLIdentifier(schema, view), err)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return nil, fmt.Errorf("Cassandra materialized view catalog has no columns for %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func materializedViewDDLFromCatalog(schema, view string, catalog materializedViewCatalog) (string, error) {
|
||||
if strings.TrimSpace(catalog.BaseTable) == "" {
|
||||
return "", fmt.Errorf("Cassandra materialized view catalog has no base table for %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
if strings.TrimSpace(catalog.WhereClause) == "" {
|
||||
return "", fmt.Errorf("Cassandra materialized view catalog has no WHERE clause for %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
|
||||
partitionKeys, clusteringColumns, selectedColumns, err := classifyMaterializedViewColumns(catalog.Columns)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Cassandra materialized view columns for %s: %w", qualifiedCQLIdentifier(schema, view), err)
|
||||
}
|
||||
if len(partitionKeys) == 0 {
|
||||
return "", fmt.Errorf("Cassandra materialized view has no partition key: %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
|
||||
projection := "*"
|
||||
if !catalog.IncludeAllColumns {
|
||||
quoted := make([]string, len(selectedColumns))
|
||||
for index, column := range selectedColumns {
|
||||
quoted[index] = quoteCQLIdentifier(column.Name)
|
||||
}
|
||||
projection = strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
primaryParts := make([]string, 0, len(clusteringColumns)+1)
|
||||
if len(partitionKeys) == 1 {
|
||||
primaryParts = append(primaryParts, quoteCQLIdentifier(partitionKeys[0].Name))
|
||||
} else {
|
||||
quoted := make([]string, len(partitionKeys))
|
||||
for index, column := range partitionKeys {
|
||||
quoted[index] = quoteCQLIdentifier(column.Name)
|
||||
}
|
||||
primaryParts = append(primaryParts, "("+strings.Join(quoted, ", ")+")")
|
||||
}
|
||||
for _, column := range clusteringColumns {
|
||||
primaryParts = append(primaryParts, quoteCQLIdentifier(column.Name))
|
||||
}
|
||||
|
||||
properties := make([]string, 0, len(catalog.Options)+1)
|
||||
if len(clusteringColumns) > 0 {
|
||||
orders := make([]string, len(clusteringColumns))
|
||||
for index, column := range clusteringColumns {
|
||||
order := strings.ToUpper(strings.TrimSpace(column.ClusteringOrder))
|
||||
if order != "ASC" && order != "DESC" {
|
||||
return "", fmt.Errorf("invalid clustering order %q for column %q", column.ClusteringOrder, column.Name)
|
||||
}
|
||||
orders[index] = quoteCQLIdentifier(column.Name) + " " + order
|
||||
}
|
||||
properties = append(properties, "CLUSTERING ORDER BY ("+strings.Join(orders, ", ")+")")
|
||||
}
|
||||
for _, name := range legacyMaterializedViewOptionNames {
|
||||
value, exists := catalog.Options[name]
|
||||
if !exists || value == nil {
|
||||
continue
|
||||
}
|
||||
properties = append(properties, name+" = "+cqlLiteral(value))
|
||||
}
|
||||
if len(properties) == 0 {
|
||||
return "", fmt.Errorf("Cassandra materialized view has no reproducible table options: %s", qualifiedCQLIdentifier(schema, view))
|
||||
}
|
||||
|
||||
return "CREATE MATERIALIZED VIEW " + qualifiedCQLIdentifier(schema, view) + " AS\n" +
|
||||
" SELECT " + projection + "\n" +
|
||||
" FROM " + qualifiedCQLIdentifier(schema, catalog.BaseTable) + "\n" +
|
||||
" WHERE " + strings.TrimSpace(catalog.WhereClause) + "\n" +
|
||||
" PRIMARY KEY (" + strings.Join(primaryParts, ", ") + ")\n" +
|
||||
" WITH " + strings.Join(properties, "\n AND ") + ";", nil
|
||||
}
|
||||
|
||||
func qualifiedCQLIdentifier(schema, name string) string {
|
||||
return quoteCQLIdentifier(schema) + "." + quoteCQLIdentifier(name)
|
||||
}
|
||||
|
||||
func classifyMaterializedViewColumns(columns []materializedViewColumn) ([]materializedViewColumn, []materializedViewColumn, []materializedViewColumn, error) {
|
||||
partitionKeys := make([]materializedViewColumn, 0)
|
||||
clusteringColumns := make([]materializedViewColumn, 0)
|
||||
regularColumns := make([]materializedViewColumn, 0)
|
||||
seenNames := make(map[string]bool, len(columns))
|
||||
for _, column := range columns {
|
||||
if strings.TrimSpace(column.Name) == "" {
|
||||
return nil, nil, nil, errors.New("column name is empty")
|
||||
}
|
||||
if seenNames[column.Name] {
|
||||
return nil, nil, nil, fmt.Errorf("duplicate column %q", column.Name)
|
||||
}
|
||||
seenNames[column.Name] = true
|
||||
switch strings.ToLower(strings.TrimSpace(column.Kind)) {
|
||||
case "partition_key":
|
||||
partitionKeys = append(partitionKeys, column)
|
||||
case "clustering", "clustering_key":
|
||||
clusteringColumns = append(clusteringColumns, column)
|
||||
case "regular", "static":
|
||||
regularColumns = append(regularColumns, column)
|
||||
default:
|
||||
return nil, nil, nil, fmt.Errorf("unsupported kind %q for column %q", column.Kind, column.Name)
|
||||
}
|
||||
}
|
||||
if err := sortMaterializedViewKeyColumns(partitionKeys, "partition"); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if err := sortMaterializedViewKeyColumns(clusteringColumns, "clustering"); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
sort.Slice(regularColumns, func(left, right int) bool { return regularColumns[left].Name < regularColumns[right].Name })
|
||||
selectedColumns := make([]materializedViewColumn, 0, len(columns))
|
||||
selectedColumns = append(selectedColumns, partitionKeys...)
|
||||
selectedColumns = append(selectedColumns, clusteringColumns...)
|
||||
selectedColumns = append(selectedColumns, regularColumns...)
|
||||
return partitionKeys, clusteringColumns, selectedColumns, nil
|
||||
}
|
||||
|
||||
func sortMaterializedViewKeyColumns(columns []materializedViewColumn, kind string) error {
|
||||
sort.Slice(columns, func(left, right int) bool {
|
||||
if columns[left].Position == columns[right].Position {
|
||||
return columns[left].Name < columns[right].Name
|
||||
}
|
||||
return columns[left].Position < columns[right].Position
|
||||
})
|
||||
for index, column := range columns {
|
||||
if column.Position < 0 {
|
||||
return fmt.Errorf("invalid %s-key position %d for column %q", kind, column.Position, column.Name)
|
||||
}
|
||||
if index > 0 && columns[index-1].Position == column.Position {
|
||||
return fmt.Errorf("duplicate %s-key position %d", kind, column.Position)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEmptyCQLCollection(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]string:
|
||||
return len(typed) == 0
|
||||
case map[string][]byte:
|
||||
return len(typed) == 0
|
||||
case map[string]any:
|
||||
return len(typed) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -470,11 +470,23 @@ func indexesFromMetadata(metadata *gocql.TableMetadata) []indexInfo {
|
||||
}
|
||||
|
||||
func (s *server) getTableDDL(schema, table string) (string, error) {
|
||||
metadata, err := s.tableMetadata(schema, table)
|
||||
keyspace, err := s.keyspaceMetadata(schema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tableDDLFromMetadata(schema, table, metadata)
|
||||
return schemaObjectDDLFromMetadata(schema, table, keyspace, func() (string, error) {
|
||||
return s.getMaterializedViewDDL(schema, table)
|
||||
})
|
||||
}
|
||||
|
||||
func schemaObjectDDLFromMetadata(schema, table string, keyspace *gocql.KeyspaceMetadata, materializedViewDDL func() (string, error)) (string, error) {
|
||||
if keyspace.MaterializedViews[table] != nil {
|
||||
return materializedViewDDL()
|
||||
}
|
||||
if metadata := keyspace.Tables[table]; metadata != nil {
|
||||
return tableDDLFromMetadata(schema, table, metadata)
|
||||
}
|
||||
return "", fmt.Errorf("Cassandra table not found: %s.%s", schema, table)
|
||||
}
|
||||
|
||||
func tableDDLFromMetadata(schema, table string, metadata *gocql.TableMetadata) (string, error) {
|
||||
|
||||
@@ -3,11 +3,21 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
type testCassandraRequestError struct {
|
||||
code int
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *testCassandraRequestError) Error() string { return e.message }
|
||||
func (e *testCassandraRequestError) Code() int { return e.code }
|
||||
func (e *testCassandraRequestError) Message() string { return e.message }
|
||||
|
||||
type triggerMetadataIterator struct {
|
||||
rows []struct {
|
||||
name string
|
||||
@@ -80,6 +90,250 @@ func TestColumnsIndexesAndDDLFromMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaObjectDDLChoosesMaterializedViewBeforeMirroredTableMetadata(t *testing.T) {
|
||||
id := &gocql.ColumnMetadata{Name: "id", Kind: gocql.ColumnPartitionKey, Type: gocql.NewNativeType(4, gocql.TypeInt, "")}
|
||||
tableMetadata := &gocql.TableMetadata{
|
||||
OrderedColumns: []string{"id"},
|
||||
PartitionKey: []*gocql.ColumnMetadata{id},
|
||||
Columns: map[string]*gocql.ColumnMetadata{"id": id},
|
||||
}
|
||||
keyspace := &gocql.KeyspaceMetadata{
|
||||
Tables: map[string]*gocql.TableMetadata{
|
||||
"users": tableMetadata,
|
||||
"users_by_email": tableMetadata,
|
||||
},
|
||||
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
|
||||
"users_by_email": {Name: "users_by_email", BaseTable: tableMetadata},
|
||||
},
|
||||
}
|
||||
|
||||
viewCalls := 0
|
||||
ddl, err := schemaObjectDDLFromMetadata("app", "users_by_email", keyspace, func() (string, error) {
|
||||
viewCalls++
|
||||
return "CREATE MATERIALIZED VIEW app.users_by_email", nil
|
||||
})
|
||||
if err != nil || ddl != "CREATE MATERIALIZED VIEW app.users_by_email" || viewCalls != 1 {
|
||||
t.Fatalf("materialized view dispatch = %q, calls = %d, err = %v", ddl, viewCalls, err)
|
||||
}
|
||||
|
||||
tableDDL, err := schemaObjectDDLFromMetadata("app", "users", keyspace, func() (string, error) {
|
||||
t.Fatal("ordinary table DDL must not use the materialized-view loader")
|
||||
return "", nil
|
||||
})
|
||||
if err != nil || !strings.HasPrefix(tableDDL, `CREATE TABLE "app"."users"`) {
|
||||
t.Fatalf("ordinary table DDL = %q, err = %v", tableDDL, err)
|
||||
}
|
||||
if _, err := schemaObjectDDLFromMetadata("app", "missing", keyspace, func() (string, error) {
|
||||
return "", errors.New("unexpected")
|
||||
}); err == nil || !strings.Contains(err.Error(), "table not found") {
|
||||
t.Fatalf("missing object error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializedViewObjectSourcePreservesProtocolShape(t *testing.T) {
|
||||
result, err := materializedViewObjectSource("app", "users_by_email", "MATERIALIZED_VIEW", func() (string, error) {
|
||||
return "CREATE MATERIALIZED VIEW app.users_by_email", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Name != "users_by_email" || result.ObjectType != "MATERIALIZED_VIEW" || result.Schema == nil || *result.Schema != "app" || result.Source != "CREATE MATERIALIZED VIEW app.users_by_email" {
|
||||
t.Fatalf("unexpected object source: %#v", result)
|
||||
}
|
||||
|
||||
loaderCalled := false
|
||||
_, err = materializedViewObjectSource("app", "users", "TABLE", func() (string, error) {
|
||||
loaderCalled = true
|
||||
return "unexpected", nil
|
||||
})
|
||||
if err == nil || loaderCalled || err.Error() != "object source is not supported by Cassandra" {
|
||||
t.Fatalf("unsupported source err = %v, loader called = %v", err, loaderCalled)
|
||||
}
|
||||
|
||||
wantErr := errors.New("definition unavailable")
|
||||
_, err = materializedViewObjectSource("app", "users_by_email", "materialized view", func() (string, error) {
|
||||
return "", wantErr
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("object-source loader error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializedViewDDLFromLegacyCatalogPreservesDefinition(t *testing.T) {
|
||||
catalog := materializedViewCatalog{
|
||||
BaseTable: `Base"Events`,
|
||||
IncludeAllColumns: false,
|
||||
WhereClause: `"Tenant Id" IS NOT NULL AND "Category" IS NOT NULL`,
|
||||
Columns: []materializedViewColumn{
|
||||
{Name: "Bucket", Kind: "clustering", Position: 1, ClusteringOrder: "asc"},
|
||||
{Name: "Payload", Kind: "regular", Position: -1},
|
||||
{Name: "Category", Kind: "partition_key", Position: 1},
|
||||
{Name: "Event Time", Kind: "clustering", Position: 0, ClusteringOrder: "desc"},
|
||||
{Name: "Tenant Id", Kind: "partition_key", Position: 0},
|
||||
},
|
||||
Options: map[string]any{
|
||||
"caching": map[string]string{"rows_per_partition": "NONE", "keys": "ALL"},
|
||||
"comment": "owner's view",
|
||||
"default_time_to_live": 0,
|
||||
"extensions": map[string][]byte{},
|
||||
"gc_grace_seconds": 86401,
|
||||
"speculative_retry": "99PERCENTILE",
|
||||
"read_repair_chance": 0.0,
|
||||
"dclocal_read_repair_chance": 0.1,
|
||||
},
|
||||
}
|
||||
|
||||
ddl, err := materializedViewDDLFromCatalog(`App"Ks`, `Events"ByCategory`, catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`CREATE MATERIALIZED VIEW "App""Ks"."Events""ByCategory" AS`,
|
||||
`SELECT "Tenant Id", "Category", "Event Time", "Bucket", "Payload"`,
|
||||
`FROM "App""Ks"."Base""Events"`,
|
||||
`WHERE "Tenant Id" IS NOT NULL AND "Category" IS NOT NULL`,
|
||||
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket")`,
|
||||
`CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`,
|
||||
`caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'}`,
|
||||
`comment = 'owner''s view'`,
|
||||
`dclocal_read_repair_chance = 0.1`,
|
||||
`gc_grace_seconds = 86401`,
|
||||
`read_repair_chance = 0`,
|
||||
`speculative_retry = '99PERCENTILE'`,
|
||||
} {
|
||||
if !strings.Contains(ddl, fragment) {
|
||||
t.Fatalf("materialized view DDL omitted %q:\n%s", fragment, ddl)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ddl, "default_time_to_live") || strings.Contains(ddl, "extensions") {
|
||||
t.Fatalf("materialized view DDL emitted non-applicable empty options:\n%s", ddl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyMaterializedViewOptionsUseVersionAppropriateReadRepair(t *testing.T) {
|
||||
modern, err := legacyMaterializedViewOptions(map[string]any{
|
||||
"read_repair": "BLOCKING",
|
||||
"read_repair_chance": 0.0,
|
||||
"dclocal_read_repair_chance": 0.0,
|
||||
"memtable": "",
|
||||
"default_time_to_live": 0,
|
||||
"extensions": map[string][]byte{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if modern["read_repair"] != "BLOCKING" || modern["read_repair_chance"] != nil || modern["dclocal_read_repair_chance"] != nil || modern["memtable"] != nil {
|
||||
t.Fatalf("unexpected modern options: %#v", modern)
|
||||
}
|
||||
|
||||
legacy, err := legacyMaterializedViewOptions(map[string]any{
|
||||
"read_repair_chance": 0.0,
|
||||
"dclocal_read_repair_chance": 0.1,
|
||||
"default_time_to_live": 0,
|
||||
"extensions": map[string][]byte{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy["read_repair_chance"] != 0.0 || legacy["dclocal_read_repair_chance"] != 0.1 {
|
||||
t.Fatalf("unexpected Cassandra 3 options: %#v", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializedViewDDLFromLegacyCatalogPreservesSelectStar(t *testing.T) {
|
||||
ddl, err := materializedViewDDLFromCatalog("app", "users_by_email", materializedViewCatalog{
|
||||
BaseTable: "users",
|
||||
IncludeAllColumns: true,
|
||||
WhereClause: "email IS NOT NULL AND id IS NOT NULL",
|
||||
Columns: []materializedViewColumn{
|
||||
{Name: "email", Kind: "partition_key", Position: 0},
|
||||
{Name: "id", Kind: "clustering", Position: 0, ClusteringOrder: "asc"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ddl, "SELECT *") {
|
||||
t.Fatalf("materialized view DDL did not preserve SELECT *:\n%s", ddl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializedViewDDLOnlyFallsBackForUnsupportedDescribeSyntax(t *testing.T) {
|
||||
legacyCalls := 0
|
||||
ddl, err := materializedViewDDLWithFallback(
|
||||
func() (string, error) { return "CREATE MATERIALIZED VIEW native", nil },
|
||||
func() (string, error) {
|
||||
legacyCalls++
|
||||
return "unexpected", nil
|
||||
},
|
||||
)
|
||||
if err != nil || ddl != "CREATE MATERIALIZED VIEW native" || legacyCalls != 0 {
|
||||
t.Fatalf("native DDL = %q, legacy calls = %d, err = %v", ddl, legacyCalls, err)
|
||||
}
|
||||
|
||||
legacyCalls = 0
|
||||
ddl, err = materializedViewDDLWithFallback(
|
||||
func() (string, error) {
|
||||
return "", &testCassandraRequestError{code: gocql.ErrCodeSyntax, message: "no viable alternative at input 'DESCRIBE'"}
|
||||
},
|
||||
func() (string, error) {
|
||||
legacyCalls++
|
||||
return "CREATE MATERIALIZED VIEW legacy", nil
|
||||
},
|
||||
)
|
||||
if err != nil || ddl != "CREATE MATERIALIZED VIEW legacy" || legacyCalls != 1 {
|
||||
t.Fatalf("syntax fallback = %q, calls = %d, err = %v", ddl, legacyCalls, err)
|
||||
}
|
||||
|
||||
permissionErr := &testCassandraRequestError{code: gocql.ErrCodeUnauthorized, message: "not authorized"}
|
||||
legacyCalls = 0
|
||||
_, err = materializedViewDDLWithFallback(
|
||||
func() (string, error) { return "", permissionErr },
|
||||
func() (string, error) {
|
||||
legacyCalls++
|
||||
return "unexpected", nil
|
||||
},
|
||||
)
|
||||
if !errors.Is(err, permissionErr) || legacyCalls != 0 {
|
||||
t.Fatalf("permission error = %v, legacy calls = %d", err, legacyCalls)
|
||||
}
|
||||
|
||||
networkErr := errors.New("connection reset")
|
||||
_, err = materializedViewDDLWithFallback(
|
||||
func() (string, error) { return "", networkErr },
|
||||
func() (string, error) {
|
||||
legacyCalls++
|
||||
return "unexpected", nil
|
||||
},
|
||||
)
|
||||
if !errors.Is(err, networkErr) || legacyCalls != 0 {
|
||||
t.Fatalf("network error = %v, legacy calls = %d", err, legacyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyMaterializedViewOptionsRejectLossyDefinitions(t *testing.T) {
|
||||
for name, row := range map[string]map[string]any{
|
||||
"unknown option": {
|
||||
"base_table_name": "users",
|
||||
"future_option": "cannot safely reproduce",
|
||||
},
|
||||
"non-zero default TTL": {
|
||||
"base_table_name": "users",
|
||||
"default_time_to_live": 60,
|
||||
},
|
||||
"non-empty extensions": {
|
||||
"base_table_name": "users",
|
||||
"extensions": map[string][]byte{"custom": {0x01}},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := legacyMaterializedViewOptions(row); err == nil {
|
||||
t.Fatal("expected an explicit error instead of a lossy legacy DDL")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataWindowAndFilter(t *testing.T) {
|
||||
values := []string{"a", "b", "c", "d"}
|
||||
if got := applyMetadataWindow(values, 1, 2); !reflect.DeepEqual(got, []string{"b", "c"}) {
|
||||
|
||||
@@ -2244,52 +2244,124 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
table
|
||||
);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// The LEFT JOIN also matches 'U': a UNIQUE constraint owns its backing index the same
|
||||
// way a primary key does, and neither can be altered with index-level DDL (#7959).
|
||||
// The constraint type therefore drives two flags — IS_PK (only 'P') and
|
||||
// CONSTRAINT_BACKED ('P' or 'U'), the same split `independentIndexes` already uses to
|
||||
// keep constraint-backed indexes out of the generated table DDL. A unique index
|
||||
// created with CREATE UNIQUE INDEX has no ALL_CONSTRAINTS row, so it stays false and
|
||||
// keeps the index-level DDL path.
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
|
||||
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
|
||||
i.UNIQUENESS,
|
||||
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
|
||||
i.INDEX_TYPE,
|
||||
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED
|
||||
FROM ALL_INDEXES i
|
||||
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
|
||||
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER
|
||||
AND c.CONSTRAINT_TYPE IN ('P', 'U')
|
||||
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
|
||||
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
|
||||
ORDER BY i.INDEX_NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
IndexInfo index = new IndexInfo(
|
||||
rs.getString(1),
|
||||
splitNonEmpty(coalesce(rs.getString(2)), ","),
|
||||
"UNIQUE".equals(rs.getString(3)),
|
||||
"1".equals(rs.getString(4)),
|
||||
null,
|
||||
rs.getString(5),
|
||||
null,
|
||||
null
|
||||
);
|
||||
index.setConstraint_backed("1".equals(rs.getString(6)));
|
||||
result.add(index);
|
||||
}
|
||||
return unchecked(() -> tableIndexes(schema, table));
|
||||
}
|
||||
|
||||
private List<IndexInfo> tableIndexes(String schema, String table) throws Exception {
|
||||
try {
|
||||
return indexesFromSystemCatalog(schema, table);
|
||||
} catch (Exception systemCatalogError) {
|
||||
if (isDamengConnectionError(systemCatalogError)) {
|
||||
throw systemCatalogError;
|
||||
}
|
||||
try {
|
||||
return indexesFromDictionaryViews(schema, table);
|
||||
} catch (Exception dictionaryViewError) {
|
||||
dictionaryViewError.addSuppressed(systemCatalogError);
|
||||
throw dictionaryViewError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<IndexInfo> indexesFromSystemCatalog(String schema, String table) throws Exception {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// ALL_INDEXES applies visibility checks across the complete catalog. Resolve the one table
|
||||
// first, then inspect only its index children; the official catalog exposes key order via
|
||||
// SF_GET_INDEX_KEY_SEQ and constraint ownership through SYSCONS.
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ index_object.NAME,
|
||||
LISTAGG(column_object.NAME, ',') WITHIN GROUP (
|
||||
ORDER BY SF_GET_INDEX_KEY_SEQ(index_metadata.KEYNUM, index_metadata.KEYINFO, column_object.COLID)
|
||||
) AS COLUMNS,
|
||||
index_metadata.ISUNIQUE,
|
||||
MAX(CASE WHEN constraint_metadata.TYPE$ = 'P' THEN 1 ELSE 0 END) AS IS_PK,
|
||||
index_metadata.TYPE$,
|
||||
MAX(CASE WHEN constraint_metadata.TYPE$ IN ('P', 'U') THEN 1 ELSE 0 END) AS CONSTRAINT_BACKED,
|
||||
index_metadata.FLAG
|
||||
FROM SYS.SYSOBJECTS schema_object
|
||||
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
|
||||
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
|
||||
JOIN SYS.SYSOBJECTS index_object ON index_object.PID = table_object.ID
|
||||
AND index_object.SUBTYPE$ = 'INDEX'
|
||||
JOIN SYS.SYSINDEXES index_metadata ON index_metadata.ID = index_object.ID
|
||||
JOIN SYS.SYSCOLUMNS column_object ON column_object.ID = table_object.ID
|
||||
AND SF_COL_IS_IDX_KEY(
|
||||
index_metadata.KEYNUM,
|
||||
index_metadata.KEYINFO,
|
||||
column_object.COLID
|
||||
) = 1
|
||||
LEFT JOIN SYS.SYSCONS constraint_metadata ON constraint_metadata.TABLEID = table_object.ID
|
||||
AND constraint_metadata.INDEXID = index_metadata.ID
|
||||
AND constraint_metadata.TYPE$ IN ('P', 'U')
|
||||
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
|
||||
GROUP BY index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
|
||||
ORDER BY index_object.NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
boolean isPrimary = "1".equals(rs.getString(4));
|
||||
boolean constraintBacked = "1".equals(rs.getString(6));
|
||||
IndexInfo index = new IndexInfo(
|
||||
rs.getString(1),
|
||||
splitNonEmpty(coalesce(rs.getString(2)), ","),
|
||||
"Y".equalsIgnoreCase(rs.getString(3)),
|
||||
isPrimary,
|
||||
null,
|
||||
damengSystemIndexType(rs.getString(5), rs.getInt(7), constraintBacked),
|
||||
null,
|
||||
null
|
||||
);
|
||||
index.setConstraint_backed(constraintBacked);
|
||||
result.add(index);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<IndexInfo> indexesFromDictionaryViews(String schema, String table) throws Exception {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// The LEFT JOIN also matches 'U': a UNIQUE constraint owns its backing index the same
|
||||
// way a primary key does, and neither can be altered with index-level DDL (#7959).
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
|
||||
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
|
||||
i.UNIQUENESS,
|
||||
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
|
||||
i.INDEX_TYPE,
|
||||
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED
|
||||
FROM ALL_INDEXES i
|
||||
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
|
||||
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER
|
||||
AND c.CONSTRAINT_TYPE IN ('P', 'U')
|
||||
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
|
||||
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
|
||||
ORDER BY i.INDEX_NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
IndexInfo index = new IndexInfo(
|
||||
rs.getString(1),
|
||||
splitNonEmpty(coalesce(rs.getString(2)), ","),
|
||||
"UNIQUE".equals(rs.getString(3)),
|
||||
"1".equals(rs.getString(4)),
|
||||
null,
|
||||
rs.getString(5),
|
||||
null,
|
||||
null
|
||||
);
|
||||
index.setConstraint_backed("1".equals(rs.getString(6)));
|
||||
result.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2298,33 +2370,108 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listForeignKeys(requireConnected(), schema, table);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
List<ForeignKeyInfo> result = new ArrayList<>();
|
||||
String sql = """
|
||||
SELECT c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME
|
||||
FROM ALL_CONSTRAINTS c
|
||||
JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER
|
||||
JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER
|
||||
JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME AND rc.OWNER = rcc.OWNER
|
||||
WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = ? AND c.TABLE_NAME = ?
|
||||
ORDER BY c.CONSTRAINT_NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new ForeignKeyInfo(
|
||||
rs.getString(1),
|
||||
rs.getString(2),
|
||||
rs.getString(3),
|
||||
rs.getString(4)
|
||||
));
|
||||
}
|
||||
return unchecked(() -> foreignKeys(schema, table));
|
||||
}
|
||||
|
||||
private List<ForeignKeyInfo> foreignKeys(String schema, String table) throws Exception {
|
||||
try {
|
||||
return foreignKeysFromSystemCatalog(schema, table);
|
||||
} catch (Exception systemCatalogError) {
|
||||
if (isDamengConnectionError(systemCatalogError)) {
|
||||
throw systemCatalogError;
|
||||
}
|
||||
try {
|
||||
return foreignKeysFromDictionaryViews(schema, table);
|
||||
} catch (Exception dictionaryViewError) {
|
||||
dictionaryViewError.addSuppressed(systemCatalogError);
|
||||
throw dictionaryViewError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ForeignKeyInfo> foreignKeysFromSystemCatalog(String schema, String table) throws Exception {
|
||||
List<ForeignKeyInfo> result = new ArrayList<>();
|
||||
String sql = """
|
||||
SELECT /*+ MAX_OPT_N_TABLES(5) PARALLEL(1) */ constraint_object.NAME,
|
||||
local_column.NAME,
|
||||
referenced_table.NAME,
|
||||
referenced_column.NAME
|
||||
FROM SYS.SYSOBJECTS schema_object
|
||||
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
|
||||
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
|
||||
JOIN SYS.SYSCONS foreign_key ON foreign_key.TABLEID = table_object.ID
|
||||
AND foreign_key.TYPE$ = 'F'
|
||||
JOIN SYS.SYSOBJECTS constraint_object ON constraint_object.ID = foreign_key.ID
|
||||
AND constraint_object.PID = table_object.ID
|
||||
AND constraint_object.SUBTYPE$ = 'CONS'
|
||||
JOIN SYS.SYSINDEXES local_index ON local_index.ID = foreign_key.INDEXID
|
||||
JOIN SYS.SYSCOLUMNS local_column ON local_column.ID = table_object.ID
|
||||
AND SF_COL_IS_IDX_KEY(local_index.KEYNUM, local_index.KEYINFO, local_column.COLID) = 1
|
||||
JOIN SYS.SYSOBJECTS referenced_index_object ON referenced_index_object.ID = foreign_key.FINDEXID
|
||||
AND referenced_index_object.SUBTYPE$ = 'INDEX'
|
||||
JOIN SYS.SYSINDEXES referenced_index ON referenced_index.ID = referenced_index_object.ID
|
||||
JOIN SYS.SYSOBJECTS referenced_table ON referenced_table.ID = referenced_index_object.PID
|
||||
AND referenced_table.TYPE$ = 'SCHOBJ' AND referenced_table.SUBTYPE$ = 'UTAB'
|
||||
JOIN SYS.SYSCOLUMNS referenced_column ON referenced_column.ID = referenced_table.ID
|
||||
AND SF_COL_IS_IDX_KEY(
|
||||
referenced_index.KEYNUM,
|
||||
referenced_index.KEYINFO,
|
||||
referenced_column.COLID
|
||||
) = 1
|
||||
AND SF_GET_INDEX_KEY_SEQ(local_index.KEYNUM, local_index.KEYINFO, local_column.COLID)
|
||||
= SF_GET_INDEX_KEY_SEQ(
|
||||
referenced_index.KEYNUM,
|
||||
referenced_index.KEYINFO,
|
||||
referenced_column.COLID
|
||||
)
|
||||
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
|
||||
ORDER BY constraint_object.NAME,
|
||||
SF_GET_INDEX_KEY_SEQ(local_index.KEYNUM, local_index.KEYINFO, local_column.COLID)
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new ForeignKeyInfo(
|
||||
rs.getString(1),
|
||||
rs.getString(2),
|
||||
rs.getString(3),
|
||||
rs.getString(4)
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ForeignKeyInfo> foreignKeysFromDictionaryViews(String schema, String table) throws Exception {
|
||||
List<ForeignKeyInfo> result = new ArrayList<>();
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME
|
||||
FROM ALL_CONSTRAINTS c
|
||||
JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER
|
||||
JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER
|
||||
JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME AND rc.OWNER = rcc.OWNER
|
||||
AND cc.POSITION = rcc.POSITION
|
||||
WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = ? AND c.TABLE_NAME = ?
|
||||
ORDER BY c.CONSTRAINT_NAME, cc.POSITION
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new ForeignKeyInfo(
|
||||
rs.getString(1),
|
||||
rs.getString(2),
|
||||
rs.getString(3),
|
||||
rs.getString(4)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2895,134 +3042,22 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private List<IndexInfo> independentIndexes(String schema, String table) throws Exception {
|
||||
try {
|
||||
return independentIndexesFromSystemCatalog(schema, table);
|
||||
} catch (Exception systemCatalogError) {
|
||||
if (isDamengConnectionError(systemCatalogError)) {
|
||||
throw systemCatalogError;
|
||||
}
|
||||
try {
|
||||
return independentIndexesFromDictionaryViews(schema, table);
|
||||
} catch (Exception dictionaryViewError) {
|
||||
dictionaryViewError.addSuppressed(systemCatalogError);
|
||||
throw dictionaryViewError;
|
||||
}
|
||||
}
|
||||
return tableIndexes(schema, table).stream()
|
||||
.filter(index -> !index.getConstraint_backed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<IndexInfo> independentIndexesFromSystemCatalog(String schema, String table) throws Exception {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// DM's ALL_INDEXES view performs privilege filtering across the complete catalog and can
|
||||
// take tens of seconds in installations with very large index dictionaries (#10075).
|
||||
// The DM JDBC driver and DBeaver instead resolve the requested table id first and read its
|
||||
// SYS index rows directly. Keep the ALL_* implementation below as a compatibility and
|
||||
// permission fallback for deployments that do not expose these catalog tables.
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ index_object.NAME,
|
||||
LISTAGG(column_object.NAME, ',') WITHIN GROUP (
|
||||
ORDER BY SF_GET_INDEX_KEY_SEQ(index_metadata.KEYNUM, index_metadata.KEYINFO, column_object.COLID)
|
||||
) AS COLUMNS,
|
||||
index_metadata.ISUNIQUE,
|
||||
index_metadata.TYPE$,
|
||||
index_metadata.FLAG
|
||||
FROM SYS.SYSOBJECTS schema_object
|
||||
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
|
||||
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
|
||||
JOIN SYS.SYSOBJECTS index_object ON index_object.PID = table_object.ID
|
||||
AND index_object.SUBTYPE$ = 'INDEX'
|
||||
JOIN SYS.SYSINDEXES index_metadata ON index_metadata.ID = index_object.ID
|
||||
JOIN SYS.SYSCOLUMNS column_object ON column_object.ID = table_object.ID
|
||||
AND SF_COL_IS_IDX_KEY(
|
||||
index_metadata.KEYNUM,
|
||||
index_metadata.KEYINFO,
|
||||
column_object.COLID
|
||||
) = 1
|
||||
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM SYS.SYSCONS constraint_metadata
|
||||
WHERE constraint_metadata.TABLEID = table_object.ID
|
||||
AND constraint_metadata.INDEXID = index_metadata.ID
|
||||
AND constraint_metadata.TYPE$ IN ('P', 'U')
|
||||
)
|
||||
GROUP BY index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
|
||||
ORDER BY index_object.NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String indexName = rs.getString(1);
|
||||
if (notBlank(indexName)) {
|
||||
result.add(new IndexInfo(
|
||||
indexName,
|
||||
splitNonEmpty(coalesce(rs.getString(2)), ","),
|
||||
"Y".equalsIgnoreCase(rs.getString(3)),
|
||||
false,
|
||||
null,
|
||||
damengSystemIndexType(rs.getString(4), rs.getInt(5)),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<IndexInfo> independentIndexesFromDictionaryViews(String schema, String table) throws Exception {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// Primary-key and unique-constraint backing indexes are already represented in table DDL.
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
|
||||
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
|
||||
i.UNIQUENESS,
|
||||
i.INDEX_TYPE
|
||||
FROM ALL_INDEXES i
|
||||
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
|
||||
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ALL_CONSTRAINTS c
|
||||
WHERE c.OWNER = i.TABLE_OWNER
|
||||
AND c.TABLE_NAME = i.TABLE_NAME
|
||||
AND c.INDEX_NAME = i.INDEX_NAME
|
||||
AND c.CONSTRAINT_TYPE IN ('P', 'U')
|
||||
)
|
||||
GROUP BY i.INDEX_NAME, i.UNIQUENESS, i.INDEX_TYPE
|
||||
ORDER BY i.INDEX_NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String indexName = rs.getString(1);
|
||||
if (notBlank(indexName)) {
|
||||
result.add(new IndexInfo(
|
||||
indexName,
|
||||
splitNonEmpty(coalesce(rs.getString(2)), ","),
|
||||
"UNIQUE".equals(rs.getString(3)),
|
||||
false,
|
||||
null,
|
||||
rs.getString(4),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String damengSystemIndexType(String catalogType, int flags) {
|
||||
if ((flags & 3) != 0) {
|
||||
private static String damengSystemIndexType(String catalogType, int flags, boolean constraintBacked) {
|
||||
if ((flags & 1) != 0 || ((flags & 2) != 0 && !constraintBacked)) {
|
||||
return "INTERNAL";
|
||||
}
|
||||
return "ST".equalsIgnoreCase(coalesce(catalogType).trim()) ? "SPATIAL" : catalogType;
|
||||
String normalized = coalesce(catalogType).trim().toUpperCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "BT" -> "NORMAL";
|
||||
case "BM" -> "BITMAP";
|
||||
case "ST" -> "SPATIAL";
|
||||
default -> catalogType;
|
||||
};
|
||||
}
|
||||
|
||||
static String indexDdl(String schema, String table, IndexInfo index) {
|
||||
|
||||
+196
-12
@@ -2,6 +2,7 @@ package com.dbx.agent.dameng;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
@@ -188,17 +189,25 @@ class DamengAgentMetadataTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablesParallelExecutionForIndexMetadataQuery() {
|
||||
void usesBoundedSystemCatalogForIndexMetadata() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
|
||||
|
||||
agent.listIndexes("APP", "USERS");
|
||||
|
||||
String indexesSql = JdbcMetadataSqlFake.statements.stream()
|
||||
.filter(sql -> sql.contains("ALL_INDEXES"))
|
||||
.filter(sql -> sql.contains("SYS.SYSINDEXES"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Assertions.assertTrue(indexesSql.startsWith("SELECT /*+ PARALLEL(1) */"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("schema_object.NAME = ?"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("table_object.NAME = ?"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("SF_COL_IS_IDX_KEY"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("SF_GET_INDEX_KEY_SEQ"), indexesSql);
|
||||
Assertions.assertTrue(
|
||||
JdbcMetadataSqlFake.statements.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")),
|
||||
String.join("\n", JdbcMetadataSqlFake.statements)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -209,12 +218,15 @@ class DamengAgentMetadataTest {
|
||||
agent.listIndexes("APP", "USERS");
|
||||
|
||||
String indexesSql = JdbcMetadataSqlFake.statements.stream()
|
||||
.filter(sql -> sql.contains("ALL_INDEXES") && sql.contains("ALL_CONSTRAINTS"))
|
||||
.filter(sql -> sql.contains("SYS.SYSINDEXES") && sql.contains("SYS.SYSCONS"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Assertions.assertTrue(indexesSql.contains("AND c.CONSTRAINT_TYPE IN ('P', 'U')"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("constraint_metadata.TYPE$ IN ('P', 'U')"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("AS CONSTRAINT_BACKED"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK"), indexesSql);
|
||||
Assertions.assertTrue(
|
||||
indexesSql.contains("MAX(CASE WHEN constraint_metadata.TYPE$ = 'P' THEN 1 ELSE 0 END) AS IS_PK"),
|
||||
indexesSql
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -222,10 +234,10 @@ class DamengAgentMetadataTest {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, indexMetadataConnection(List.of(
|
||||
// UNIQUE constraint (虚索引) / standalone CREATE UNIQUE INDEX (实索引) / plain index.
|
||||
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "0", "NORMAL", "1"),
|
||||
Arrays.asList("UX_USERS_CODE", "CODE", "UNIQUE", "0", "NORMAL", "0"),
|
||||
Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"),
|
||||
Arrays.asList("PK_USERS", "ID", "UNIQUE", "1", "NORMAL", "1")
|
||||
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "Y", "0", "BT", "1", 0),
|
||||
Arrays.asList("UX_USERS_CODE", "CODE", "Y", "0", "BT", "0", 0),
|
||||
Arrays.asList("IDX_USERS_NAME", "NAME", "N", "0", "BT", "0", 0),
|
||||
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4)
|
||||
)));
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
|
||||
@@ -246,6 +258,142 @@ class DamengAgentMetadataTest {
|
||||
List.of(true, true, false, true),
|
||||
indexes.stream().map(IndexInfo::getIs_unique).toList()
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
List.of("NORMAL", "NORMAL", "NORMAL", "NORMAL"),
|
||||
indexes.stream().map(IndexInfo::getIndex_type).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToDictionaryViewsForIndexMetadataWithoutSystemCatalogAccess() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
|
||||
sqls,
|
||||
"SYS.SYSINDEXES index_metadata",
|
||||
List.of(),
|
||||
new SQLException("no SYS index catalog privilege"),
|
||||
"FROM ALL_INDEXES i",
|
||||
List.of(Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"))
|
||||
));
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
|
||||
|
||||
Assertions.assertEquals(List.of("IDX_USERS_NAME"), indexes.stream().map(IndexInfo::getName).toList());
|
||||
Assertions.assertTrue(indexOfSql(sqls, "SYS.SYSINDEXES") >= 0, String.join("\n", sqls));
|
||||
Assertions.assertTrue(
|
||||
indexOfSql(sqls, "ALL_INDEXES") > indexOfSql(sqls, "SYS.SYSINDEXES"),
|
||||
String.join("\n", sqls)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsEmptySystemIndexMetadataWithoutRunningExpensiveFallback() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
|
||||
sqls,
|
||||
"SYS.SYSINDEXES index_metadata",
|
||||
List.of(),
|
||||
null,
|
||||
"FROM ALL_INDEXES i",
|
||||
List.of(Arrays.asList("SHOULD_NOT_LOAD", "ID", "NONUNIQUE", "0", "NORMAL", "0"))
|
||||
));
|
||||
|
||||
Assertions.assertTrue(agent.listIndexes("APP", "USERS").isEmpty());
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesBoundedSystemCatalogForForeignKeyMetadata() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
|
||||
sqls,
|
||||
"SYS.SYSCONS foreign_key",
|
||||
List.of(
|
||||
List.of("FK_ORDER_CUSTOMER", "TENANT_ID", "CUSTOMERS", "TENANT_ID"),
|
||||
List.of("FK_ORDER_CUSTOMER", "CUSTOMER_ID", "CUSTOMERS", "ID")
|
||||
),
|
||||
null,
|
||||
"FROM ALL_CONSTRAINTS c",
|
||||
List.of()
|
||||
));
|
||||
|
||||
List<ForeignKeyInfo> foreignKeys = agent.listForeignKeys("APP", "ORDERS");
|
||||
|
||||
Assertions.assertEquals(List.of("TENANT_ID", "CUSTOMER_ID"), foreignKeys.stream().map(ForeignKeyInfo::getColumn).toList());
|
||||
Assertions.assertEquals(List.of("TENANT_ID", "ID"), foreignKeys.stream().map(ForeignKeyInfo::getRef_column).toList());
|
||||
String sql = sqls.get(0);
|
||||
Assertions.assertTrue(sql.contains("foreign_key.TABLEID = table_object.ID"), sql);
|
||||
Assertions.assertTrue(sql.contains("foreign_key.FINDEXID"), sql);
|
||||
Assertions.assertTrue(sql.contains("foreign_key.TYPE$ = 'F'"), sql);
|
||||
Assertions.assertTrue(sql.contains("SF_GET_INDEX_KEY_SEQ"), sql);
|
||||
Assertions.assertTrue(sql.contains("schema_object.NAME = ?"), sql);
|
||||
Assertions.assertTrue(sql.contains("table_object.NAME = ?"), sql);
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(query -> query.contains("ALL_CONSTRAINTS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToPositionMatchedDictionaryForeignKeysWithoutSystemCatalogAccess() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
|
||||
sqls,
|
||||
"SYS.SYSCONS foreign_key",
|
||||
List.of(),
|
||||
new SQLException("no SYS constraint catalog privilege"),
|
||||
"FROM ALL_CONSTRAINTS c",
|
||||
List.of(List.of("FK_ORDER_CUSTOMER", "CUSTOMER_ID", "CUSTOMERS", "ID"))
|
||||
));
|
||||
|
||||
List<ForeignKeyInfo> foreignKeys = agent.listForeignKeys("APP", "ORDERS");
|
||||
|
||||
Assertions.assertEquals(1, foreignKeys.size());
|
||||
Assertions.assertEquals("FK_ORDER_CUSTOMER", foreignKeys.get(0).getName());
|
||||
int systemQuery = indexOfSql(sqls, "SYS.SYSCONS foreign_key");
|
||||
int fallbackQuery = indexOfSql(sqls, "ALL_CONSTRAINTS");
|
||||
Assertions.assertTrue(fallbackQuery > systemQuery, String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.get(fallbackQuery).contains("cc.POSITION = rcc.POSITION"), sqls.get(fallbackQuery));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsEmptySystemForeignKeysWithoutRunningExpensiveFallback() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
|
||||
sqls,
|
||||
"SYS.SYSCONS foreign_key",
|
||||
List.of(),
|
||||
null,
|
||||
"FROM ALL_CONSTRAINTS c",
|
||||
List.of(List.of("SHOULD_NOT_LOAD", "ID", "PARENT", "ID"))
|
||||
));
|
||||
|
||||
Assertions.assertTrue(agent.listForeignKeys("APP", "ORDERS").isEmpty());
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_CONSTRAINTS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRetryForeignKeyCatalogAfterConnectionFailure() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
|
||||
sqls,
|
||||
"SYS.SYSCONS foreign_key",
|
||||
List.of(),
|
||||
new SQLNonTransientConnectionException("connection lost"),
|
||||
"FROM ALL_CONSTRAINTS c",
|
||||
List.of()
|
||||
));
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.listForeignKeys("APP", "ORDERS")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("connection lost", error.getCause().getMessage());
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_CONSTRAINTS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2178,7 +2326,8 @@ class DamengAgentMetadataTest {
|
||||
List.of(
|
||||
indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0),
|
||||
indexRow("UX_USERS_EMAIL", "EMAIL", "Y", "BT", 0),
|
||||
indexRow("IDX_USERS_GEO", "GEO", "N", "ST", 0)
|
||||
indexRow("IDX_USERS_GEO", "GEO", "N", "ST", 0),
|
||||
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4)
|
||||
),
|
||||
sqls
|
||||
);
|
||||
@@ -2480,7 +2629,7 @@ class DamengAgentMetadataTest {
|
||||
}
|
||||
|
||||
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType, int flags) {
|
||||
return List.of(name, columns, uniqueness, indexType, flags);
|
||||
return List.of(name, columns, uniqueness, "0", indexType, "0", flags);
|
||||
}
|
||||
|
||||
private static List<List<Object>> dictionaryIndexRows(List<List<Object>> systemRows) {
|
||||
@@ -2488,10 +2637,45 @@ class DamengAgentMetadataTest {
|
||||
row.get(0),
|
||||
row.get(1),
|
||||
"Y".equals(row.get(2)) ? "UNIQUE" : "NONUNIQUE",
|
||||
"ST".equals(row.get(3)) ? "SPATIAL" : "NORMAL"
|
||||
row.get(3),
|
||||
"ST".equals(row.get(4)) ? "SPATIAL" : "NORMAL",
|
||||
row.get(5)
|
||||
)).toList();
|
||||
}
|
||||
|
||||
private static Connection catalogFallbackConnection(
|
||||
List<String> sqls,
|
||||
String systemQueryMarker,
|
||||
List<List<Object>> systemRows,
|
||||
SQLException systemError,
|
||||
String fallbackQueryMarker,
|
||||
List<List<Object>> fallbackRows
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("prepareStatement".equals(name)) {
|
||||
String sql = (String) args[0];
|
||||
sqls.add(sql);
|
||||
if (sql.contains(systemQueryMarker)) {
|
||||
return systemError == null
|
||||
? metadataStatement(systemRows)
|
||||
: failingMetadataStatement(systemError);
|
||||
}
|
||||
if (sql.contains(fallbackQueryMarker)) {
|
||||
return metadataStatement(fallbackRows);
|
||||
}
|
||||
throw new AssertionError("Unexpected metadata query: " + sql);
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static int indexOfSql(List<String> sqls, String fragment) {
|
||||
for (int i = 0; i < sqls.size(); i++) {
|
||||
if (sqls.get(i).contains(fragment)) {
|
||||
|
||||
@@ -290,6 +290,31 @@ func TestDecodeQueryOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQueryStatementRecognizesListStatements(t *testing.T) {
|
||||
tests := []struct {
|
||||
sql string
|
||||
want bool
|
||||
}{
|
||||
{sql: "LIST USER", want: true},
|
||||
{sql: "list role", want: true},
|
||||
{sql: " LiSt PRIVILEGES OF USER root; ", want: true},
|
||||
{sql: "\tLIST PRIVILEGES OF ROLE dbx7359_role\n", want: true},
|
||||
{sql: "SHOW VERSION", want: true},
|
||||
{sql: "SELECT * FROM root.dbx_7359.d", want: true},
|
||||
{sql: ""},
|
||||
{sql: " ; "},
|
||||
{sql: "INSERT INTO root.dbx_7359.d(time,s) VALUES(1,1)"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.sql, func(t *testing.T) {
|
||||
if got := isQueryStatement(trimStatementSQL(test.sql)); got != test.want {
|
||||
t.Fatalf("isQueryStatement(%q) = %t, want %t", test.sql, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelActiveOperationInvalidatesClient(t *testing.T) {
|
||||
blocking := &blockingIoTDBSession{closed: make(chan struct{})}
|
||||
connected := &sessionClient{session: blocking, dialect: client.TreeSqlDialect}
|
||||
|
||||
@@ -556,7 +556,7 @@ func isQueryStatement(sql string) bool {
|
||||
return false
|
||||
}
|
||||
switch strings.ToUpper(fields[0]) {
|
||||
case "SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN", "WITH":
|
||||
case "SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN", "WITH", "LIST":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.12.1'
|
||||
implementation 'org.apache.kafka:kafka-clients:3.9.0'
|
||||
implementation 'org.apache.kafka:kafka-clients:3.9.1'
|
||||
implementation('org.apache.zookeeper:zookeeper:3.8.4') {
|
||||
exclude group: 'log4j', module: 'log4j'
|
||||
exclude group: 'org.slf4j', module: 'slf4j-log4j12'
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.dbx.agent.kafka;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.security.auth.Subject;
|
||||
import javax.security.auth.callback.Callback;
|
||||
import javax.security.auth.callback.NameCallback;
|
||||
import javax.security.auth.callback.PasswordCallback;
|
||||
import org.apache.kafka.common.internals.SecurityManagerCompatibility;
|
||||
import org.apache.kafka.common.security.authenticator.SaslClientCallbackHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class KafkaClientCompatibilityTest {
|
||||
@Test
|
||||
void passwordSaslCallbacksUseTheCurrentSubject() {
|
||||
// KAFKA-17078 routes Subject access through this shim when newer JDKs
|
||||
// disable the legacy Subject.getSubject API.
|
||||
Subject subject = new Subject();
|
||||
subject.getPublicCredentials().add("dbx-user");
|
||||
subject.getPrivateCredentials().add("dbx-password");
|
||||
|
||||
for (String mechanism : List.of("PLAIN", "SCRAM-SHA-256", "SCRAM-SHA-512")) {
|
||||
SaslClientCallbackHandler handler = new SaslClientCallbackHandler();
|
||||
handler.configure(Map.of(), mechanism, List.of());
|
||||
NameCallback name = new NameCallback("username");
|
||||
PasswordCallback password = new PasswordCallback("password", false);
|
||||
|
||||
assertDoesNotThrow(() -> SecurityManagerCompatibility.get().callAs(subject, () -> {
|
||||
handler.handle(new Callback[] { name, password });
|
||||
return null;
|
||||
}));
|
||||
assertEquals("dbx-user", name.getName());
|
||||
assertArrayEquals("dbx-password".toCharArray(), password.getPassword());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,10 +24,28 @@ const (
|
||||
defaultManagementTLSPort = 15671
|
||||
defaultAMQPTLSPort = 5671
|
||||
managementPageSize = 100
|
||||
managementMaxPageSize = 500
|
||||
managementConnectTimeout = 10 * time.Second
|
||||
managementRequestTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
type managementPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Search string
|
||||
UseRegex bool
|
||||
Sort string
|
||||
SortReverse bool
|
||||
}
|
||||
|
||||
type managementPageResult struct {
|
||||
Items []any
|
||||
Page int
|
||||
PageSize int
|
||||
TotalCount int
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
type managementStatusError struct {
|
||||
status int
|
||||
method string
|
||||
@@ -172,6 +193,204 @@ func managementGetAll(connection jsonObject, path string) ([]any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// managementGetPage requests exactly one RabbitMQ management API page. Queue
|
||||
// and exchange endpoints in RabbitMQ 3.8.x return a pagination envelope when
|
||||
// page/page_size are present. The plain-array fallback keeps the agent usable
|
||||
// with endpoints or older brokers that ignore those parameters, while still
|
||||
// presenting the same explicit page contract to callers.
|
||||
func managementGetPage(connection jsonObject, path string, request managementPageRequest) (managementPageResult, error) {
|
||||
if err := validateManagementPageRequest(request); err != nil {
|
||||
return managementPageResult{}, err
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("page", strconv.Itoa(request.Page))
|
||||
query.Set("page_size", strconv.Itoa(request.PageSize))
|
||||
if request.Search != "" {
|
||||
query.Set("name", request.Search)
|
||||
query.Set("use_regex", strconv.FormatBool(request.UseRegex))
|
||||
}
|
||||
if request.Sort != "" {
|
||||
query.Set("sort", request.Sort)
|
||||
}
|
||||
if request.SortReverse {
|
||||
query.Set("sort_reverse", "true")
|
||||
}
|
||||
|
||||
response, err := managementGet(connection, appendManagementQuery(path, query))
|
||||
if err != nil {
|
||||
return managementPageResult{}, err
|
||||
}
|
||||
switch typed := response.(type) {
|
||||
case []any:
|
||||
return paginateManagementArray(typed, request)
|
||||
case map[string]any:
|
||||
object := jsonObject(typed)
|
||||
items, exists := typed["items"]
|
||||
if !exists {
|
||||
return managementPageResult{}, fmt.Errorf("Unexpected management API response for list endpoint %s", path)
|
||||
}
|
||||
array, ok := items.([]any)
|
||||
if !ok {
|
||||
return managementPageResult{}, fmt.Errorf("Unexpected management API items for list endpoint %s", path)
|
||||
}
|
||||
page := intOrDefault(object, "page", request.Page)
|
||||
pageSize := intOrDefault(object, "page_size", request.PageSize)
|
||||
totalCount := integerOrNull(object, "filtered_count")
|
||||
if totalCount == nil {
|
||||
totalCount = integerOrNull(object, "total_count")
|
||||
}
|
||||
if totalCount == nil {
|
||||
inferred := (page-1)*pageSize + len(array)
|
||||
totalCount = &inferred
|
||||
}
|
||||
pageCount := integerOrNull(object, "page_count")
|
||||
hasMore := pageCount != nil && page < *pageCount
|
||||
if pageCount == nil {
|
||||
hasMore = page*pageSize < *totalCount
|
||||
}
|
||||
return managementPageResult{
|
||||
Items: array,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalCount: *totalCount,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
default:
|
||||
return managementPageResult{}, fmt.Errorf("Unexpected management API response for list endpoint %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func managementPageRequestFromParams(params jsonObject, allowedSorts ...string) (*managementPageRequest, error) {
|
||||
page := integerOrNull(params, "page")
|
||||
pageSize := integerOrNull(params, "page_size")
|
||||
if page == nil && pageSize == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if page == nil || pageSize == nil {
|
||||
return nil, errors.New("page and page_size must be provided together")
|
||||
}
|
||||
sortName := stringOrDefault(params, "sort", "name")
|
||||
allowed := false
|
||||
for _, candidate := range allowedSorts {
|
||||
if sortName == candidate {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, fmt.Errorf("Unsupported list sort %q", sortName)
|
||||
}
|
||||
request := managementPageRequest{
|
||||
Page: *page,
|
||||
PageSize: *pageSize,
|
||||
Search: strings.TrimSpace(stringOrEmpty(params, "search")),
|
||||
Sort: sortName,
|
||||
SortReverse: boolOrDefault(params, "sort_reverse", false),
|
||||
}
|
||||
if err := validateManagementPageRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &request, nil
|
||||
}
|
||||
|
||||
func validateManagementPageRequest(request managementPageRequest) error {
|
||||
if request.Page < 1 {
|
||||
return errors.New("page must be at least 1")
|
||||
}
|
||||
if request.PageSize < 1 || request.PageSize > managementMaxPageSize {
|
||||
return fmt.Errorf("page_size must be between 1 and %d", managementMaxPageSize)
|
||||
}
|
||||
maxInt := int(^uint(0) >> 1)
|
||||
if request.Page-1 > maxInt/request.PageSize {
|
||||
return errors.New("page is too large")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendManagementQuery(path string, values url.Values) string {
|
||||
separator := "?"
|
||||
if strings.Contains(path, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return path + separator + values.Encode()
|
||||
}
|
||||
|
||||
func paginateManagementArray(items []any, request managementPageRequest) (managementPageResult, error) {
|
||||
filtered := make([]any, 0, len(items))
|
||||
var expression *regexp.Regexp
|
||||
if request.Search != "" && request.UseRegex {
|
||||
compiled, err := regexp.Compile("(?i)" + request.Search)
|
||||
if err != nil {
|
||||
return managementPageResult{}, fmt.Errorf("Invalid management list search: %w", err)
|
||||
}
|
||||
expression = compiled
|
||||
}
|
||||
search := strings.ToLower(request.Search)
|
||||
for _, item := range items {
|
||||
if request.Search != "" {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := stringOrEmpty(jsonObject(object), "name")
|
||||
if expression != nil {
|
||||
if !expression.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
} else if !strings.Contains(strings.ToLower(name), search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
sort.SliceStable(filtered, func(left, right int) bool {
|
||||
leftObject, _ := filtered[left].(map[string]any)
|
||||
rightObject, _ := filtered[right].(map[string]any)
|
||||
leftJSON := jsonObject(leftObject)
|
||||
rightJSON := jsonObject(rightObject)
|
||||
comparison := 0
|
||||
if request.Sort == "messages_ready" {
|
||||
leftCount := longOrDefault(leftJSON, "messages_ready", 0)
|
||||
rightCount := longOrDefault(rightJSON, "messages_ready", 0)
|
||||
if leftCount < rightCount {
|
||||
comparison = -1
|
||||
} else if leftCount > rightCount {
|
||||
comparison = 1
|
||||
}
|
||||
}
|
||||
if comparison == 0 {
|
||||
comparison = strings.Compare(stringOrEmpty(leftJSON, "name"), stringOrEmpty(rightJSON, "name"))
|
||||
}
|
||||
if request.SortReverse {
|
||||
comparison = -comparison
|
||||
}
|
||||
return comparison < 0
|
||||
})
|
||||
|
||||
totalCount := len(filtered)
|
||||
start := (request.Page - 1) * request.PageSize
|
||||
if start >= totalCount {
|
||||
return managementPageResult{
|
||||
Items: []any{},
|
||||
Page: request.Page,
|
||||
PageSize: request.PageSize,
|
||||
TotalCount: totalCount,
|
||||
HasMore: false,
|
||||
}, nil
|
||||
}
|
||||
end := start + request.PageSize
|
||||
if end > totalCount {
|
||||
end = totalCount
|
||||
}
|
||||
return managementPageResult{
|
||||
Items: filtered[start:end],
|
||||
Page: request.Page,
|
||||
PageSize: request.PageSize,
|
||||
TotalCount: totalCount,
|
||||
HasMore: end < totalCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func managementBaseURLs(connection jsonObject) ([]string, error) {
|
||||
if explicit := stringOrNull(connection, "management_url"); explicit != nil && strings.TrimSpace(*explicit) != "" {
|
||||
return []string{normalizeManagementURL(*explicit)}, nil
|
||||
|
||||
@@ -135,6 +135,134 @@ func TestManagementGetAllPlainArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementGetPageRequestsOnlyOneRabbitMqPage(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestCount++
|
||||
query := request.URL.Query()
|
||||
if query.Get("page") != "2" || query.Get("page_size") != "100" {
|
||||
t.Fatalf("unexpected pagination query %q", request.URL.RawQuery)
|
||||
}
|
||||
if query.Get("name") != "orders & paid" || query.Get("use_regex") != "false" {
|
||||
t.Fatalf("unexpected search query %q", request.URL.RawQuery)
|
||||
}
|
||||
if query.Get("sort") != "messages_ready" || query.Get("sort_reverse") != "true" {
|
||||
t.Fatalf("unexpected sort query %q", request.URL.RawQuery)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(writer, `{"items":[{"name":"orders.paid"}],"page":2,"page_size":100,"page_count":3,"total_count":10000,"filtered_count":250}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
page, err := managementGetPage(jsonObject{"management_url": server.URL}, "/api/queues?columns=name", managementPageRequest{
|
||||
Page: 2, PageSize: 100, Search: "orders & paid", Sort: "messages_ready", SortReverse: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("expected one management request, got %d", requestCount)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Page != 2 || page.PageSize != 100 || page.TotalCount != 250 || !page.HasMore {
|
||||
t.Fatalf("unexpected page %#v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementGetPageFallsBackForPlainArrayResponses(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(writer, `[{"name":"alpha","messages_ready":1},{"name":"beta","messages_ready":9},{"name":"gamma","messages_ready":4}]`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
page, err := managementGetPage(jsonObject{"management_url": server.URL}, "/api/queues", managementPageRequest{
|
||||
Page: 1, PageSize: 2, Search: "a", Sort: "messages_ready", SortReverse: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.TotalCount != 3 || !page.HasMore || len(page.Items) != 2 {
|
||||
t.Fatalf("unexpected page %#v", page)
|
||||
}
|
||||
first, _ := page.Items[0].(map[string]any)
|
||||
second, _ := page.Items[1].(map[string]any)
|
||||
if stringOrEmpty(jsonObject(first), "name") != "beta" || stringOrEmpty(jsonObject(second), "name") != "gamma" {
|
||||
t.Fatalf("unexpected sorted items %#v", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementPageRequestValidation(t *testing.T) {
|
||||
for _, params := range []jsonObject{
|
||||
{"page": 0, "page_size": 100, "sort": "name"},
|
||||
{"page": 1, "page_size": managementMaxPageSize + 1, "sort": "name"},
|
||||
{"page": 1},
|
||||
{"page": 1, "page_size": 100, "sort": "unsupported"},
|
||||
} {
|
||||
if _, err := managementPageRequestFromParams(params, "name"); err == nil {
|
||||
t.Fatalf("expected invalid page request %#v to fail", params)
|
||||
}
|
||||
}
|
||||
if page, err := managementPageRequestFromParams(jsonObject{}, "name"); err != nil || page != nil {
|
||||
t.Fatalf("legacy unpaged request changed: %#v, %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTopicsPagePreservesAllVhostIdentityWithoutDrainingPages(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestCount++
|
||||
if request.URL.Path != "/api/queues" {
|
||||
t.Fatalf("unexpected all-vhosts path %q", request.URL.Path)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(writer, `{"items":[{"name":"orders","vhost":"staging","durable":true}],"page":1,"page_size":100,"page_count":100,"total_count":10000,"filtered_count":10000}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
service := newServer()
|
||||
service.cachedConnection = jsonObject{"management_url": server.URL}
|
||||
|
||||
listed, err := service.listTopics(jsonObject{
|
||||
"all_vhosts": true, "page": 1, "page_size": 100, "sort": "name",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := listed.(jsonObject)
|
||||
topics := result["topics"].([]jsonObject)
|
||||
if requestCount != 1 || len(topics) != 1 || stringOrEmpty(topics[0], "vhost") != "staging" {
|
||||
t.Fatalf("unexpected paged topics %#v after %d requests", result, requestCount)
|
||||
}
|
||||
if intOrDefault(result, "totalCount", 0) != 10000 || !boolOrDefault(result, "hasMore", false) {
|
||||
t.Fatalf("unexpected page metadata %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExchangesPageSearchIncludesDisplayedDefaultExchange(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
query := request.URL.Query()
|
||||
if query.Get("use_regex") != "true" || !strings.Contains(query.Get("name"), "^$") {
|
||||
t.Fatalf("default exchange search was not translated to a regex: %q", request.URL.RawQuery)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(writer, `{"items":[{"name":"","type":"direct","durable":true}],"page":1,"page_size":100,"page_count":1,"total_count":8,"filtered_count":1}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
service := newServer()
|
||||
service.cachedConnection = jsonObject{"management_url": server.URL}
|
||||
|
||||
listed, err := service.listExchanges(jsonObject{
|
||||
"virtual_host": "/", "page": 1, "page_size": 100, "search": "default", "sort": "name",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := listed.(jsonObject)
|
||||
exchanges := result["exchanges"].([]jsonObject)
|
||||
if len(exchanges) != 1 || stringOrEmpty(exchanges[0], "name") != "" || intOrDefault(result, "totalCount", 0) != 1 {
|
||||
t.Fatalf("unexpected exchange page %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRequestFailsOverConnectionErrors(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.2:0")
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -30,7 +31,22 @@ func (s *server) listTopics(params jsonObject) (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
allVhosts := allVhostsRequested(params)
|
||||
queues, err := managementGetAll(connection, managementListPath(params, connection, "queues"))
|
||||
pageRequest, err := managementPageRequestFromParams(params, "name", "messages_ready")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var queues []any
|
||||
var pageResult *managementPageResult
|
||||
if pageRequest == nil {
|
||||
queues, err = managementGetAll(connection, managementListPath(params, connection, "queues"))
|
||||
} else {
|
||||
page, pageErr := managementGetPage(connection, managementListPath(params, connection, "queues"), *pageRequest)
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
queues = page.Items
|
||||
pageResult = &page
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -46,10 +62,12 @@ func (s *server) listTopics(params jsonObject) (any, error) {
|
||||
}
|
||||
topics = append(topics, info)
|
||||
}
|
||||
sort.SliceStable(topics, func(left, right int) bool {
|
||||
return stringOrEmpty(topics[left], "name") < stringOrEmpty(topics[right], "name")
|
||||
})
|
||||
return jsonObject{"topics": topics}, nil
|
||||
if pageRequest == nil {
|
||||
sort.SliceStable(topics, func(left, right int) bool {
|
||||
return stringOrEmpty(topics[left], "name") < stringOrEmpty(topics[right], "name")
|
||||
})
|
||||
}
|
||||
return managementListResult("topics", topics, pageResult), nil
|
||||
}
|
||||
|
||||
func topicInfoFromJSON(queue jsonObject) jsonObject {
|
||||
@@ -382,7 +400,29 @@ func (s *server) listExchanges(params jsonObject) (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
allVhosts := allVhostsRequested(params)
|
||||
exchanges, err := managementGetAll(connection, managementListPath(params, connection, "exchanges"))
|
||||
pageRequest, err := managementPageRequestFromParams(params, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pageRequest != nil && pageRequest.Search != "" && strings.Contains(strings.ToLower("(AMQP default)"), strings.ToLower(pageRequest.Search)) {
|
||||
// The UI displays the empty-name default exchange as "(AMQP default)".
|
||||
// Include it in the broker-side name search whenever that display label
|
||||
// matches, while retaining ordinary substring matches.
|
||||
pageRequest.Search = "(" + regexp.QuoteMeta(pageRequest.Search) + "|^$)"
|
||||
pageRequest.UseRegex = true
|
||||
}
|
||||
var exchanges []any
|
||||
var pageResult *managementPageResult
|
||||
if pageRequest == nil {
|
||||
exchanges, err = managementGetAll(connection, managementListPath(params, connection, "exchanges"))
|
||||
} else {
|
||||
page, pageErr := managementGetPage(connection, managementListPath(params, connection, "exchanges"), *pageRequest)
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
exchanges = page.Items
|
||||
pageResult = &page
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -398,10 +438,24 @@ func (s *server) listExchanges(params jsonObject) (any, error) {
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
sort.SliceStable(result, func(left, right int) bool {
|
||||
return stringOrEmpty(result[left], "name") < stringOrEmpty(result[right], "name")
|
||||
})
|
||||
return jsonObject{"exchanges": result}, nil
|
||||
if pageRequest == nil {
|
||||
sort.SliceStable(result, func(left, right int) bool {
|
||||
return stringOrEmpty(result[left], "name") < stringOrEmpty(result[right], "name")
|
||||
})
|
||||
}
|
||||
return managementListResult("exchanges", result, pageResult), nil
|
||||
}
|
||||
|
||||
func managementListResult(key string, items []jsonObject, page *managementPageResult) jsonObject {
|
||||
result := jsonObject{key: items}
|
||||
if page == nil {
|
||||
return result
|
||||
}
|
||||
result["page"] = page.Page
|
||||
result["pageSize"] = page.PageSize
|
||||
result["totalCount"] = page.TotalCount
|
||||
result["hasMore"] = page.HasMore
|
||||
return result
|
||||
}
|
||||
|
||||
func exchangeInfoFromJSON(exchange jsonObject) jsonObject {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bigquery": "0.1.69",
|
||||
"cache": "0.1.13",
|
||||
"cassandra": "0.1.50",
|
||||
"dameng": "0.1.78",
|
||||
"dameng": "0.1.79",
|
||||
"databend": "0.1.56",
|
||||
"databricks": "0.1.63",
|
||||
"db2": "0.1.69",
|
||||
@@ -15,7 +15,7 @@
|
||||
"goldendb": "0.1.68",
|
||||
"h2": "0.1.69",
|
||||
"h2-legacy": "0.1.38",
|
||||
"hive": "0.1.55",
|
||||
"hive": "0.1.56",
|
||||
"argo": "0.1.0",
|
||||
"ignite": "0.1.21",
|
||||
"ignite3": "0.1.21",
|
||||
@@ -43,12 +43,12 @@
|
||||
"oscar": "0.1.43",
|
||||
"xugu": "0.1.47",
|
||||
"iris": "0.1.62",
|
||||
"iotdb": "0.1.42",
|
||||
"iotdb": "0.1.43",
|
||||
"etcd": "0.1.43",
|
||||
"etcd2": "0.1.1",
|
||||
"zookeeper": "0.1.31",
|
||||
"kafka": "0.1.15",
|
||||
"kafka": "0.1.16",
|
||||
"rocketmq": "0.1.13",
|
||||
"rabbitmq": "0.1.11",
|
||||
"sqlserver-legacy": "0.1.41"
|
||||
"rabbitmq": "0.1.12",
|
||||
"sqlserver-legacy": "0.1.42"
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ import { uuid } from "@/lib/common/utils";
|
||||
import { isMacOS, isWindows } from "@/lib/backend/platform";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { activeTabExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, unassociatedExternalSqlFileTarget, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
@@ -2435,8 +2435,7 @@ async function saveActiveSqlAsLocalFile() {
|
||||
if (tab) await saveExternalSqlTabAs(tab);
|
||||
}
|
||||
|
||||
function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
function applyExternalSqlTarget(tab: QueryTab, target: ExternalSqlFileTarget) {
|
||||
if (target.connectionId !== tab.connectionId) {
|
||||
queryStore.updateConnection(tab.id, target.connectionId, target.database);
|
||||
}
|
||||
@@ -2449,6 +2448,13 @@ function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
|
||||
if (target.schema !== tab.schema) queryStore.updateSchema(tab.id, target.schema);
|
||||
}
|
||||
|
||||
function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
|
||||
applyExternalSqlTarget(
|
||||
tab,
|
||||
resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, tab.id, (connectionId) => connectionStore.getConfig(connectionId)),
|
||||
);
|
||||
}
|
||||
|
||||
async function openSqlFile() {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
@@ -2477,6 +2483,10 @@ async function openSqlFile() {
|
||||
if (!file) return;
|
||||
try {
|
||||
queryStore.updateSql(tab.id, await readBrowserSqlFile(file, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)));
|
||||
applyExternalSqlTarget(
|
||||
tab,
|
||||
activeTabExternalSqlFileTarget(queryStore.tabs, tab.id, (connectionId) => connectionStore.getConfig(connectionId)),
|
||||
);
|
||||
} catch (e: any) {
|
||||
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
|
||||
}
|
||||
@@ -2520,6 +2530,8 @@ async function openSqlFilePath(path: string) {
|
||||
try {
|
||||
await desktopOpenTabsRestorationBarrier?.settled;
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
// Startup and OS-open events have no initiating editor tab. Keep new files
|
||||
// unassociated even if persisted tab restoration has since selected one.
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
|
||||
} catch (e: any) {
|
||||
@@ -3290,7 +3302,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
if (item.type === "content_match" && item.filePath) {
|
||||
try {
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, { line: item.line ?? 1, column: item.column });
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
@@ -3304,7 +3316,7 @@ async function handleQuickOpenSelect(item: any) {
|
||||
if (item.type === "sql_file" && item.filePath) {
|
||||
try {
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema);
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
@@ -4198,6 +4210,8 @@ onUnmounted(() => {
|
||||
@open-sql-file="dialogs.showSqlFileDialog.value = true"
|
||||
@open-schema-diff="dialogs.showSchemaDiffDialog.value = true"
|
||||
@open-data-compare="dialogs.showDataCompareDialog.value = true"
|
||||
@open-backups="openSettings('backups')"
|
||||
@open-mcp-settings="openSettings('mcp')"
|
||||
/>
|
||||
|
||||
<div :class="isDetachedWindowContext ? 'flex-1 flex min-h-0' : isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
|
||||
|
||||
@@ -2231,8 +2231,10 @@ html.dark .driver-store-agent-row--installed::before {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Storage tab: cards scroll together inside the tab */
|
||||
.driver-store-storage-tab {
|
||||
/* Storage tab: cards scroll together inside the tab. The selector must out-specify
|
||||
`.driver-store-tabs-root > [data-slot="tabs-content"]` above, otherwise the
|
||||
`overflow: hidden` there wins and the cards get clipped with no way to scroll. */
|
||||
.driver-store-tabs-root > [data-slot="tabs-content"].driver-store-storage-tab {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
@@ -2241,6 +2243,12 @@ html.dark .driver-store-agent-row--installed::before {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Cards must keep their natural height, otherwise `flex` squeezes the runtime
|
||||
list inside the last card instead of letting the tab scroll. */
|
||||
.driver-store-tabs-root > [data-slot="tabs-content"].driver-store-storage-tab > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.driver-store-agent-name,
|
||||
.driver-store-jdbc-name {
|
||||
flex: 1 1 auto !important;
|
||||
|
||||
@@ -148,7 +148,7 @@ import {
|
||||
} from "@lucide/vue";
|
||||
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleObjectFiltersNeedReset } from "@/lib/connection/connectionVisibleDatabases";
|
||||
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
|
||||
import { isSchemaAware, isSingleDatabase, supportsDataDictionary } from "@/lib/database/databaseFeatureSupport";
|
||||
import { normalizeConnectionScope, normalizeConnectionTimeouts } from "@/lib/connection/connectionSubmitNormalization";
|
||||
import { databaseConnectionFormKind } from "@/lib/database/databaseDriverManifest";
|
||||
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
|
||||
@@ -231,6 +231,7 @@ const DREMIO_ARROW_FLIGHT_SQL_JDBC_DRIVER_CLASS = "org.apache.arrow.driver.jdbc.
|
||||
const DREMIO_LEGACY_JDBC_URL = "jdbc:dremio:direct=127.0.0.1:31010";
|
||||
const DREMIO_LEGACY_JDBC_DRIVER_CLASS = "com.dremio.jdbc.Driver";
|
||||
const DEFAULT_SSH_USER = "root";
|
||||
const DIRECT_SIDEBAR_OBJECT_TYPES = new Set<DatabaseType>(["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "mqtt", "nacos", "plugin"]);
|
||||
const ETCD_GRPC_MAX_INBOUND_DEFAULT_MIB = 32;
|
||||
const ETCD_GRPC_MAX_INBOUND_MIN_MIB = 1;
|
||||
const ETCD_GRPC_MAX_INBOUND_MAX_MIB = 256;
|
||||
@@ -441,6 +442,7 @@ const defaultForm = (): ConnectionForm => ({
|
||||
docs_notes_path: undefined,
|
||||
read_only: false,
|
||||
show_system_schemas: false,
|
||||
sidebar_auto_load_all_tables: false,
|
||||
is_production: false,
|
||||
production_databases: [],
|
||||
visible_databases: undefined,
|
||||
@@ -632,6 +634,7 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
|
||||
}
|
||||
|
||||
const form = ref(defaultForm());
|
||||
const supportsAutomaticTableLoading = computed(() => supportsDataDictionary(form.value.db_type) && !DIRECT_SIDEBAR_OBJECT_TYPES.has(form.value.db_type));
|
||||
const redisKeyTemplatesText = ref("");
|
||||
const noteTextareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const showGaussdbConnectionMode = computed(() => form.value.db_type === "gaussdb");
|
||||
@@ -3110,6 +3113,7 @@ watch(
|
||||
docs_notes_path: config.docs_notes_path,
|
||||
read_only: config.read_only || false,
|
||||
show_system_schemas: config.show_system_schemas || false,
|
||||
sidebar_auto_load_all_tables: config.sidebar_auto_load_all_tables === true,
|
||||
is_production: config.is_production || false,
|
||||
production_databases: config.production_databases || [],
|
||||
visible_databases: config.visible_databases,
|
||||
@@ -5141,6 +5145,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
|
||||
}
|
||||
if (!config.show_system_schemas) config.show_system_schemas = undefined;
|
||||
if (!config.sidebar_auto_load_all_tables) config.sidebar_auto_load_all_tables = undefined;
|
||||
if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined;
|
||||
if (config.agent_java_options && config.agent_java_options.length === 0) config.agent_java_options = undefined;
|
||||
// Pasted credentials may carry invisible characters that trim() keeps (#9043).
|
||||
@@ -9878,6 +9883,16 @@ function openExternalUrl(url: string) {
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.showSystemSchemasHint") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="supportsAutomaticTableLoading" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.tableLoading") }}</Label>
|
||||
<div class="col-span-3 grid gap-1.5">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<Switch v-model="form.sidebar_auto_load_all_tables" />
|
||||
<span class="text-sm font-medium">{{ t("connection.autoLoadAllTables") }}</span>
|
||||
</label>
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.autoLoadAllTablesHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Documentation notes are a relational-only feature, so this
|
||||
follows the same isSchemaAware gate as the row above. -->
|
||||
<div v-if="isSchemaAware(form.db_type)" class="grid grid-cols-4 items-start gap-4">
|
||||
|
||||
+2
-1
@@ -234,7 +234,7 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
|
||||
});
|
||||
|
||||
it("updates the existing ID only after Save and preserves omitted fields", async () => {
|
||||
await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=new-password");
|
||||
await mountDialog(savedConnection({ sidebar_auto_load_all_tables: true }), "dbx://connection/new?id=saved-production&password=new-password");
|
||||
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
|
||||
expect(save).toBeTruthy();
|
||||
save!.click();
|
||||
@@ -255,6 +255,7 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
|
||||
is_production: true,
|
||||
production_databases: ["orders"],
|
||||
visible_databases: ["orders"],
|
||||
sidebar_auto_load_all_tables: true,
|
||||
url_params: "connectTimeout=25",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -121,7 +121,7 @@ watch([sourceDbType, targetDbType], ([src, tgt]) => {
|
||||
});
|
||||
const optionTree = computed(() => {
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
const dbType = targetConfig?.db_type || "postgres";
|
||||
const dbType = schemaDiffEngineDatabaseType(targetConfig) || targetConfig?.db_type || "postgres";
|
||||
return getSchemaDiffOptionsForDbType(dbType);
|
||||
});
|
||||
|
||||
@@ -1647,12 +1647,14 @@ const targetConnectionInfo = computed(() => {
|
||||
|
||||
<!-- Options Panel Overlay -->
|
||||
<div v-if="showOptionsPanel" class="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center" @click.self="showOptionsPanel = false">
|
||||
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex h-[calc(100%-2rem)] max-h-[680px] w-[900px] max-w-[calc(100%-2rem)] flex-col overflow-hidden rounded-lg border bg-card shadow-lg">
|
||||
<div class="flex shrink-0 items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-medium">{{ t("schemaDiff.optionsTitle") }}</h3>
|
||||
<Button variant="ghost" size="sm" @click="showOptionsPanel = false" :aria-label="t('common.close')">✕</Button>
|
||||
</div>
|
||||
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
|
||||
<div class="min-h-0 flex-1 p-4">
|
||||
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { ListChecks, ListFilter, Settings2, TableProperties } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -22,6 +23,13 @@ const { t } = useI18n();
|
||||
// The panel is a transactional editor: it is recreated each time it opens and
|
||||
// only commits its local snapshot when the user clicks Done.
|
||||
const localOptions = ref<SchemaDiffCompareOptions>(normalizeSchemaDiffCompareOptions(props.options));
|
||||
const objectOptionIds = new Set<BooleanSchemaDiffCompareOptionKey>(["views", "functions", "indexes", "sequences", "triggers", "rules", "owners"]);
|
||||
const tableDetailOptionIds = new Set<BooleanSchemaDiffCompareOptionKey>(["compareColumnOrder", "compareCharset"]);
|
||||
|
||||
const tableOption = computed(() => props.optionTree.find((item) => item.id === "tables"));
|
||||
const objectOptions = computed(() => props.optionTree.filter((item) => objectOptionIds.has(item.id)));
|
||||
const tableStructureOptions = computed(() => [...(tableOption.value?.children ?? []), ...props.optionTree.filter((item) => tableDetailOptionIds.has(item.id))]);
|
||||
const behaviorOptions = computed(() => props.optionTree.filter((item) => item.id !== "tables" && !objectOptionIds.has(item.id) && !tableDetailOptionIds.has(item.id)));
|
||||
|
||||
function isChecked(id: BooleanSchemaDiffCompareOptionKey): boolean {
|
||||
return !!localOptions.value[id];
|
||||
@@ -66,7 +74,7 @@ function handleCancel() {
|
||||
}
|
||||
|
||||
function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): string {
|
||||
const base = "h-4 w-4 rounded border flex items-center justify-center transition-colors cursor-pointer";
|
||||
const base = "flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors";
|
||||
if (state === "checked") {
|
||||
return `${base} bg-primary border-primary text-primary-foreground`;
|
||||
}
|
||||
@@ -75,104 +83,165 @@ function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): strin
|
||||
}
|
||||
return `${base} bg-background border-input hover:border-muted-foreground`;
|
||||
}
|
||||
|
||||
function getItemRowClasses(item: SchemaDiffOptionItem): string {
|
||||
const state = getChildState(item);
|
||||
const base = "group flex min-h-8 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||
return state === "unchecked" ? `${base} text-foreground hover:bg-muted/60` : `${base} bg-primary/5 text-foreground hover:bg-primary/10`;
|
||||
}
|
||||
|
||||
function getAriaChecked(item: SchemaDiffOptionItem): boolean | "mixed" {
|
||||
const state = getChildState(item);
|
||||
return state === "indeterminate" ? "mixed" : state === "checked";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="grid flex-1 grid-cols-[minmax(0,1fr)_minmax(300px,0.95fr)] gap-4 overflow-auto pr-1">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="px-2 pb-1 text-xs font-medium text-muted-foreground">{{ t("schemaDiff.optionsTitle") }}</div>
|
||||
<div class="space-y-1">
|
||||
<template v-for="item in optionTree" :key="item.id">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(item)">
|
||||
<div :class="getItemClasses(getChildState(item))">
|
||||
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<svg v-else-if="getChildState(item) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(item.labelKey) }}</span>
|
||||
</div>
|
||||
<div v-if="item.children" class="ml-6 space-y-1">
|
||||
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(child)">
|
||||
<div :class="getItemClasses(getChildState(child))">
|
||||
<svg v-if="getChildState(child) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm select-none">{{ t(child.labelKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="grid min-h-0 flex-1 grid-cols-[minmax(0,1.15fr)_minmax(300px,0.85fr)] overflow-hidden">
|
||||
<div class="min-h-0 space-y-4 overflow-y-auto pr-4">
|
||||
<section data-section="object-scope">
|
||||
<div class="mb-2 flex items-center gap-2 text-xs font-semibold">
|
||||
<ListChecks class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<h4>{{ t("schemaDiff.options.objectScopeSection") }}</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button v-if="tableOption" type="button" role="checkbox" :aria-checked="getAriaChecked(tableOption)" :class="getItemRowClasses(tableOption)" @click="toggleItem(tableOption)">
|
||||
<span :class="getItemClasses(getChildState(tableOption))">
|
||||
<svg v-if="getChildState(tableOption) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
<svg v-else-if="getChildState(tableOption) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
</span>
|
||||
<span class="select-none">{{ t(tableOption.labelKey) }}</span>
|
||||
</button>
|
||||
<button v-for="item in objectOptions" :key="item.id" type="button" role="checkbox" :aria-checked="getAriaChecked(item)" :class="getItemRowClasses(item)" @click="toggleItem(item)">
|
||||
<span :class="getItemClasses(getChildState(item))">
|
||||
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
</span>
|
||||
<span class="select-none">{{ t(item.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-section="table-structure" class="border-t pt-4">
|
||||
<div class="mb-2 flex items-center gap-2 text-xs font-semibold">
|
||||
<TableProperties class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<h4>{{ t("schemaDiff.options.tableStructureSection") }}</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button v-for="item in tableStructureOptions" :key="item.id" type="button" role="checkbox" :aria-checked="getAriaChecked(item)" :class="getItemRowClasses(item)" @click="toggleItem(item)">
|
||||
<span :class="getItemClasses(getChildState(item))">
|
||||
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
</span>
|
||||
<span class="select-none">{{ t(item.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="behaviorOptions.length" data-section="compare-behavior" class="border-t pt-4">
|
||||
<div class="mb-2 flex items-center gap-2 text-xs font-semibold">
|
||||
<Settings2 class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<h4>{{ t("schemaDiff.options.behaviorSection") }}</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button v-for="item in behaviorOptions" :key="item.id" type="button" role="checkbox" :aria-checked="getAriaChecked(item)" :class="getItemRowClasses(item)" @click="toggleItem(item)">
|
||||
<span :class="getItemClasses(getChildState(item))">
|
||||
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
</span>
|
||||
<span class="select-none">{{ t(item.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 grid-cols-[112px_minmax(0,1fr)] content-start gap-x-3 gap-y-2 border-l pl-4">
|
||||
<div class="pt-1 text-xs font-medium text-muted-foreground">{{ t("schemaDiff.tableFilter") }}</div>
|
||||
<div class="flex min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate text-xs text-muted-foreground">{{ t("schemaDiff.tableFilterHintShort") }}</span>
|
||||
<HelpTooltip :label="t('schemaDiff.tableFilterHelp')" side="left">
|
||||
<div class="space-y-1">
|
||||
<div>{{ t("schemaDiff.tableFilterHelpRule") }}</div>
|
||||
<div>{{ t("schemaDiff.tableFilterHelpBlank") }}</div>
|
||||
<div>{{ t("schemaDiff.tableFilterHelpExample") }}</div>
|
||||
<div class="min-h-0 space-y-4 overflow-y-auto border-l pl-4 pr-1">
|
||||
<section>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs font-semibold">
|
||||
<ListFilter class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<h4>{{ t("schemaDiff.tableFilter") }}</h4>
|
||||
</div>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-include">{{ t("schemaDiff.tableIncludePattern") }}</label>
|
||||
<input id="schema-diff-table-include" v-model="localOptions.tableIncludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableIncludePatternPlaceholder')" />
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-exclude">{{ t("schemaDiff.tableExcludePattern") }}</label>
|
||||
<input id="schema-diff-table-exclude" v-model="localOptions.tableExcludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableExcludePatternPlaceholder')" />
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-priority">{{ t("schemaDiff.tableFilterPriority") }}</label>
|
||||
<div>
|
||||
<Select v-model="localOptions.tableFilterPriority">
|
||||
<SelectTrigger id="schema-diff-table-priority" class="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exclude">{{ t("schemaDiff.tableFilterPriorityExclude") }}</SelectItem>
|
||||
<SelectItem value="include">{{ t("schemaDiff.tableFilterPriorityInclude") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<HelpTooltip :label="t('schemaDiff.tableFilterHelp')" side="left">
|
||||
<div class="space-y-1">
|
||||
<div>{{ t("schemaDiff.tableFilterHelpRule") }}</div>
|
||||
<div>{{ t("schemaDiff.tableFilterHelpBlank") }}</div>
|
||||
<div>{{ t("schemaDiff.tableFilterHelpExample") }}</div>
|
||||
</div>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<div class="space-y-1">
|
||||
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-table-include">{{ t("schemaDiff.tableIncludePattern") }}</label>
|
||||
<input id="schema-diff-table-include" v-model="localOptions.tableIncludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableIncludePatternPlaceholder')" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-table-exclude">{{ t("schemaDiff.tableExcludePattern") }}</label>
|
||||
<input id="schema-diff-table-exclude" v-model="localOptions.tableExcludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableExcludePatternPlaceholder')" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-table-priority">{{ t("schemaDiff.tableFilterPriority") }}</label>
|
||||
<Select v-model="localOptions.tableFilterPriority">
|
||||
<SelectTrigger id="schema-diff-table-priority" class="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exclude">{{ t("schemaDiff.tableFilterPriorityExclude") }}</SelectItem>
|
||||
<SelectItem value="include">{{ t("schemaDiff.tableFilterPriorityInclude") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Advanced options -->
|
||||
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.advancedSection") }}</div>
|
||||
<section class="border-t pt-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-xs font-semibold">
|
||||
<Settings2 class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<h4>{{ t("schemaDiff.options.advancedSection") }}</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-rename-threshold">{{ t("schemaDiff.options.renameThreshold") }}</label>
|
||||
<span class="font-mono text-[11px]" :class="localOptions.detectRenames || localOptions.detectTableRenames ? 'text-foreground' : 'text-muted-foreground'">{{ localOptions.renameThreshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
<input
|
||||
id="schema-diff-rename-threshold"
|
||||
v-model.number="localOptions.renameThreshold"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
:disabled="!localOptions.detectRenames && !localOptions.detectTableRenames"
|
||||
class="h-1.5 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-compat-threshold">{{ t("schemaDiff.options.compatibilityThreshold") }}</label>
|
||||
<span class="font-mono text-[11px] text-foreground">{{ localOptions.compatibilityThreshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
<input id="schema-diff-compat-threshold" v-model.number="localOptions.compatibilityThreshold" type="range" min="0" max="1" step="0.05" class="h-1.5 w-full cursor-pointer accent-primary" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-source-dialect">{{ t("schemaDiff.options.sourceDialect") }}</label>
|
||||
<input id="schema-diff-source-dialect" v-model="localOptions.sourceDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-target-dialect">{{ t("schemaDiff.options.targetDialect") }}</label>
|
||||
<input id="schema-diff-target-dialect" v-model="localOptions.targetDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-rename-threshold">{{ t("schemaDiff.options.renameThreshold") }}</label>
|
||||
<div v-if="localOptions.detectRenames || localOptions.detectTableRenames" class="flex items-center gap-2 pt-1">
|
||||
<input id="schema-diff-rename-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.renameThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
|
||||
<span class="text-xs font-mono w-10 text-right">{{ localOptions.renameThreshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-else class="pt-1 text-xs text-muted-foreground italic">
|
||||
{{ t("schemaDiff.options.detectRenames") }}
|
||||
</div>
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-compat-threshold">{{ t("schemaDiff.options.compatibilityThreshold") }}</label>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<input id="schema-diff-compat-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.compatibilityThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
|
||||
<span class="text-xs font-mono w-10 text-right">{{ localOptions.compatibilityThreshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-source-dialect">{{ t("schemaDiff.options.sourceDialect") }}</label>
|
||||
<input id="schema-diff-source-dialect" v-model="localOptions.sourceDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-target-dialect">{{ t("schemaDiff.options.targetDialect") }}</label>
|
||||
<input id="schema-diff-target-dialect" v-model="localOptions.targetDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
|
||||
|
||||
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.batchSection") }}</div>
|
||||
|
||||
<div class="flex items-center gap-2 col-span-2 pt-1">
|
||||
<input id="schema-diff-batch-patterns" v-model="localOptions.batchPatterns" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.batchPatterns')" />
|
||||
</div>
|
||||
<section class="border-t pt-4">
|
||||
<h4 class="mb-2.5 text-xs font-semibold">{{ t("schemaDiff.options.batchSection") }}</h4>
|
||||
<input id="schema-diff-batch-patterns" v-model="localOptions.batchPatterns" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.batchPatterns')" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-4 border-t mt-2">
|
||||
<div class="mt-3 flex shrink-0 items-center justify-end gap-2 border-t pt-3">
|
||||
<Button variant="outline" size="sm" @click="handleCancel">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
|
||||
@@ -58,6 +58,33 @@ describe("SchemaDiffOptionsPanel", () => {
|
||||
expect(normalized.ignoreColumnNameCase).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes charset comparison only for supported MySQL targets and defaults it on", () => {
|
||||
expect(getSchemaDiffOptionsForDbType("mysql").map((item) => item.id)).toContain("compareCharset");
|
||||
expect(getSchemaDiffOptionsForDbType("goldendb").map((item) => item.id)).toContain("compareCharset");
|
||||
expect(getSchemaDiffOptionsForDbType("starrocks").map((item) => item.id)).not.toContain("compareCharset");
|
||||
expect(getSchemaDiffOptionsForDbType("doris").map((item) => item.id)).not.toContain("compareCharset");
|
||||
expect(getSchemaDiffOptionsForDbType("postgres").map((item) => item.id)).not.toContain("compareCharset");
|
||||
expect(normalizeSchemaDiffCompareOptions({}, "mysql").compareCharset).toBe(true);
|
||||
});
|
||||
|
||||
it("groups MySQL charset comparison with table structure options", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
|
||||
const app = createApp(SchemaDiffOptionsPanel, {
|
||||
options: normalizeSchemaDiffCompareOptions({}, "mysql"),
|
||||
optionTree: getSchemaDiffOptionsForDbType("mysql"),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
const tableStructure = host.querySelector('[data-section="table-structure"]');
|
||||
const behavior = host.querySelector('[data-section="compare-behavior"]');
|
||||
expect(tableStructure?.textContent).toContain("schemaDiff.options.compareCharset");
|
||||
expect(behavior?.textContent).not.toContain("schemaDiff.options.compareCharset");
|
||||
});
|
||||
|
||||
it("keeps unsaved checkbox edits when the parent refreshes equivalent options", async () => {
|
||||
const options = ref<SchemaDiffCompareOptions>({ ...DEFAULT_POSTGRES_OPTIONS });
|
||||
const optionTree: SchemaDiffOptionItem[] = [{ id: "views", labelKey: "views", defaultChecked: true }];
|
||||
|
||||
@@ -66,6 +66,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
||||
import { useUserSkillStore } from "@/stores/userSkillStore";
|
||||
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
|
||||
import { aiConversationTypographyCssVariables } from "@/lib/ai/aiTypography";
|
||||
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
|
||||
import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport";
|
||||
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
|
||||
@@ -210,6 +211,12 @@ const AiHtmlPreview = defineAsyncComponent({
|
||||
loader: () => import("@/components/ai/rich/AiHtmlPreview.vue"),
|
||||
});
|
||||
const settings = useSettingsStore();
|
||||
const aiTypographyStyle = computed(() =>
|
||||
aiConversationTypographyCssVariables({
|
||||
fontFamily: settings.editorSettings.aiFontFamily,
|
||||
fontSize: settings.editorSettings.aiFontSize,
|
||||
}),
|
||||
);
|
||||
const connectionStore = useConnectionStore();
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
const promptTemplateStore = usePromptTemplateStore();
|
||||
@@ -5419,7 +5426,7 @@ async function openExternalUrl(url: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="assistantRootRef" data-ai-assistant-root class="flex h-full min-h-0 flex-col overflow-hidden" @dragenter="onAttachmentDragEnter" @dragover="onAttachmentDragOver" @dragleave="onAttachmentDragLeave" @drop="onAttachmentDrop">
|
||||
<div ref="assistantRootRef" data-ai-assistant-root class="flex h-full min-h-0 flex-col overflow-hidden" :style="aiTypographyStyle" @dragenter="onAttachmentDragEnter" @dragover="onAttachmentDragOver" @dragleave="onAttachmentDragLeave" @drop="onAttachmentDrop">
|
||||
<div class="flex items-center gap-2 border-b px-3 shrink-0" :class="settings.editorSettings.appLayout === 'classic' ? 'h-9' : 'h-10'">
|
||||
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
|
||||
{{ chatTitle }}
|
||||
@@ -5655,7 +5662,7 @@ async function openExternalUrl(url: string) {
|
||||
data-edit-textarea
|
||||
v-model="editingContent"
|
||||
rows="3"
|
||||
class="w-full resize-none rounded-lg border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
|
||||
class="ai-conversation-text w-full resize-none rounded-lg border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
|
||||
@keydown="onEditKeydown($event, i)"
|
||||
@compositionstart="editCompositionActive = true"
|
||||
@compositionend="editCompositionActive = false"
|
||||
@@ -5707,7 +5714,7 @@ async function openExternalUrl(url: string) {
|
||||
class="w-44"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="messageReferenceMentions(msg).length || msg.content" class="min-w-0 rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
|
||||
<div v-if="messageReferenceMentions(msg).length || msg.content" class="ai-conversation-text min-w-0 rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
|
||||
<div v-if="messageReferenceMentions(msg).length" class="mb-1.5 flex flex-wrap justify-end gap-1">
|
||||
<button
|
||||
v-for="mention in messageReferenceMentions(msg)"
|
||||
@@ -5777,7 +5784,7 @@ async function openExternalUrl(url: string) {
|
||||
|
||||
<!-- Keep the metadata row as wide as the reply card so its export action stays right-aligned. -->
|
||||
<div v-else-if="msg.content || msg.reasoning || msg.isThinking" class="flex w-full max-w-[95%] min-w-0 flex-col">
|
||||
<div class="w-full rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed [overflow-wrap:anywhere]">
|
||||
<div data-ai-assistant-message-content class="ai-conversation-text w-full rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed [overflow-wrap:anywhere]">
|
||||
<div v-if="msg.reasoning || msg.isThinking" class="mb-2">
|
||||
<button class="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors" @click="toggleReasoning()">
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-200" :class="{ 'rotate-90': reasoningExpanded }" />
|
||||
@@ -6330,7 +6337,7 @@ async function openExternalUrl(url: string) {
|
||||
ref="promptTextareaRef"
|
||||
v-model="prompt"
|
||||
:style="{ height: `${textareaHeight}px`, maxHeight: `${maxTextareaHeight()}px` }"
|
||||
class="w-full resize-none bg-transparent text-xs outline-none placeholder:text-muted-foreground mb-1"
|
||||
class="ai-conversation-text w-full resize-none bg-transparent text-xs outline-none placeholder:text-muted-foreground mb-1"
|
||||
:placeholder="activePlaceholder"
|
||||
@input="refreshMentionState"
|
||||
@click="refreshMentionState"
|
||||
@@ -6675,6 +6682,11 @@ async function openExternalUrl(url: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-conversation-text {
|
||||
font-family: var(--dbx-ai-content-font-family, inherit);
|
||||
font-size: var(--dbx-ai-content-font-size, 0.75rem);
|
||||
}
|
||||
|
||||
.ai-prompt-context-row--compact .ai-prompt-context-spacer {
|
||||
flex: 0 0 0;
|
||||
}
|
||||
@@ -6791,7 +6803,7 @@ async function openExternalUrl(url: string) {
|
||||
border-radius: 0.25rem;
|
||||
background: var(--muted);
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 11px;
|
||||
font-size: var(--dbx-ai-inline-code-font-size, 11px);
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.ai-markdown :deep(pre) {
|
||||
@@ -6873,6 +6885,11 @@ html.dbx-legacy-webview.dark .ai-markdown :deep(.ai-markdown-table-wrap:hover::-
|
||||
min-height: 1lh;
|
||||
}
|
||||
|
||||
.ai-code-block {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: var(--dbx-ai-code-font-size, 0.75rem);
|
||||
}
|
||||
|
||||
.ai-message-scroll :deep([data-slot="scroll-area-viewport"]) {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
@@ -271,6 +271,16 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
||||
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
|
||||
import { currentLocale, previewLocale, restoreLocalePreview, setLocale, type Locale } from "@/i18n";
|
||||
import {
|
||||
AI_CONVERSATION_FONT_FAMILY_DEFAULT,
|
||||
AI_CONVERSATION_FONT_SIZE_DEFAULT,
|
||||
AI_CONVERSATION_FONT_SIZE_MAX,
|
||||
AI_CONVERSATION_FONT_SIZE_MIN,
|
||||
aiConversationFontFamilyForName,
|
||||
normalizeAiConversationFontFamily,
|
||||
normalizeAiConversationFontFamilyInput,
|
||||
normalizeAiConversationFontSize,
|
||||
} from "@/lib/ai/aiTypography";
|
||||
import {
|
||||
SETTINGS_SEARCH_DEFINITIONS,
|
||||
TOOLBAR_VISIBILITY_ITEMS,
|
||||
@@ -592,6 +602,9 @@ function createEmptyTableColumnTemplateRow(): TableColumnTemplateGridRow {
|
||||
// Local edit state
|
||||
const editFontFamily = ref(settingsStore.editorSettings.fontFamily);
|
||||
const editFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
const editAiFontFamily = ref(settingsStore.editorSettings.aiFontFamily);
|
||||
const editAiFontSize = ref<number | string>(settingsStore.editorSettings.aiFontSize);
|
||||
const aiTypographySaving = ref(false);
|
||||
const editTableFontFamily = ref(settingsStore.editorSettings.tableFontFamily);
|
||||
const editUiFontFamily = ref(settingsStore.editorSettings.uiFontFamily);
|
||||
const editUiScale = ref(settingsStore.editorSettings.uiScale);
|
||||
@@ -651,6 +664,7 @@ const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
|
||||
const editShowWhitespace = ref(settingsStore.editorSettings.showWhitespace);
|
||||
const editDdlOpenMode = ref<EditorSettings["ddlOpenMode"]>(settingsStore.editorSettings.ddlOpenMode);
|
||||
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
|
||||
const editDoubleClickStringSelectionMode = ref<EditorSettings["doubleClickStringSelectionMode"]>(settingsStore.editorSettings.doubleClickStringSelectionMode);
|
||||
const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets);
|
||||
const editSqlSemanticDiagnosticsMode = ref<SqlSemanticDiagnosticsMode>(settingsStore.editorSettings.sqlSemanticDiagnosticsMode);
|
||||
const editSqlSemanticDiagnosticsEnabled = ref(settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
|
||||
@@ -1011,6 +1025,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
showWhitespace: editShowWhitespace.value,
|
||||
ddlOpenMode: editDdlOpenMode.value,
|
||||
vimModeEnabled: editVimModeEnabled.value,
|
||||
doubleClickStringSelectionMode: editDoubleClickStringSelectionMode.value,
|
||||
autoCloseBrackets: editAutoCloseBrackets.value,
|
||||
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value,
|
||||
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
|
||||
@@ -1589,6 +1604,12 @@ const systemFontOptions = computed(() => {
|
||||
|
||||
const tableFontOptions = computed(() => buildFontFamilyOptions(systemFonts.value, [editTableFontFamily.value], [DEFAULT_DATA_GRID_FONT_FAMILY]));
|
||||
|
||||
const aiFontOptions = computed(() => {
|
||||
const options = new Set([DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY, ...systemFonts.value.map(aiConversationFontFamilyForName)]);
|
||||
if (editAiFontFamily.value) options.add(editAiFontFamily.value);
|
||||
return [...options];
|
||||
});
|
||||
|
||||
const uiFontOptions = computed(() => {
|
||||
const options = new Set([SYSTEM_UI_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, ...systemFontOptions.value]);
|
||||
if (editUiFontFamily.value) options.add(editUiFontFamily.value);
|
||||
@@ -1601,6 +1622,11 @@ function displayUiFontFamily(value: string): string {
|
||||
return displayFontFamily(value);
|
||||
}
|
||||
|
||||
function displayAiFontFamily(value: string): string {
|
||||
if (!value) return t("ai.conversationFontFollowInterface");
|
||||
return displayUiFontFamily(value);
|
||||
}
|
||||
|
||||
function fontOptionStyle(value: string, selectedValue = editFontFamily.value) {
|
||||
return isPresetFontFamily(value) || uiFontPreviewValues.has(value) || value === selectedValue ? { fontFamily: value } : undefined;
|
||||
}
|
||||
@@ -1630,6 +1656,8 @@ const hasImportedSettingsPendingApply = ref(false);
|
||||
function syncEditorSettingsDraftFromStore() {
|
||||
editFontFamily.value = settingsStore.editorSettings.fontFamily;
|
||||
editFontSize.value = settingsStore.editorSettings.fontSize;
|
||||
editAiFontFamily.value = settingsStore.editorSettings.aiFontFamily;
|
||||
editAiFontSize.value = settingsStore.editorSettings.aiFontSize;
|
||||
editTableFontFamily.value = settingsStore.editorSettings.tableFontFamily;
|
||||
editUiFontFamily.value = settingsStore.editorSettings.uiFontFamily;
|
||||
editUiScale.value = settingsStore.editorSettings.uiScale;
|
||||
@@ -1658,6 +1686,7 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editShowWhitespace.value = settingsStore.editorSettings.showWhitespace;
|
||||
editDdlOpenMode.value = settingsStore.editorSettings.ddlOpenMode;
|
||||
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
|
||||
editDoubleClickStringSelectionMode.value = settingsStore.editorSettings.doubleClickStringSelectionMode;
|
||||
editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets;
|
||||
editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode;
|
||||
editSqlSemanticDiagnosticsEnabled.value = settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
|
||||
@@ -1796,6 +1825,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
|
||||
showWhitespace: editShowWhitespace,
|
||||
ddlOpenMode: editDdlOpenMode,
|
||||
vimModeEnabled: editVimModeEnabled,
|
||||
doubleClickStringSelectionMode: editDoubleClickStringSelectionMode,
|
||||
autoCloseBrackets: editAutoCloseBrackets,
|
||||
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode,
|
||||
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution,
|
||||
@@ -2286,6 +2316,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace;
|
||||
editDdlOpenMode.value = DEFAULT_EDITOR_SETTINGS.ddlOpenMode;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
editDoubleClickStringSelectionMode.value = DEFAULT_EDITOR_SETTINGS.doubleClickStringSelectionMode;
|
||||
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
|
||||
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
|
||||
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
|
||||
@@ -2444,6 +2475,7 @@ function resetAllDefaults() {
|
||||
editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace;
|
||||
editDdlOpenMode.value = DEFAULT_EDITOR_SETTINGS.ddlOpenMode;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
editDoubleClickStringSelectionMode.value = DEFAULT_EDITOR_SETTINGS.doubleClickStringSelectionMode;
|
||||
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
|
||||
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
|
||||
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
|
||||
@@ -2716,6 +2748,44 @@ function onUiFontFamilyChange(v: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function persistAiTypography(partial: Partial<Pick<EditorSettings, "aiFontFamily" | "aiFontSize">>) {
|
||||
aiTypographySaving.value = true;
|
||||
try {
|
||||
await settingsStore.updateEditorSettingsAndPersist(partial);
|
||||
} catch (error) {
|
||||
applySettingsErrorToast(error);
|
||||
} finally {
|
||||
editAiFontFamily.value = settingsStore.editorSettings.aiFontFamily;
|
||||
editAiFontSize.value = settingsStore.editorSettings.aiFontSize;
|
||||
aiTypographySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAiFontFamilyChange(value: unknown) {
|
||||
const fontFamily = normalizeAiConversationFontFamily(value);
|
||||
editAiFontFamily.value = fontFamily;
|
||||
if (fontFamily !== settingsStore.editorSettings.aiFontFamily) void persistAiTypography({ aiFontFamily: fontFamily });
|
||||
}
|
||||
|
||||
function commitAiFontSize() {
|
||||
const fontSize = normalizeAiConversationFontSize(editAiFontSize.value);
|
||||
editAiFontSize.value = fontSize;
|
||||
if (fontSize !== settingsStore.editorSettings.aiFontSize) void persistAiTypography({ aiFontSize: fontSize });
|
||||
}
|
||||
|
||||
function blurAiFontSizeInput(event: KeyboardEvent) {
|
||||
(event.target as HTMLInputElement | null)?.blur();
|
||||
}
|
||||
|
||||
function resetAiTypography() {
|
||||
editAiFontFamily.value = AI_CONVERSATION_FONT_FAMILY_DEFAULT;
|
||||
editAiFontSize.value = AI_CONVERSATION_FONT_SIZE_DEFAULT;
|
||||
void persistAiTypography({
|
||||
aiFontFamily: AI_CONVERSATION_FONT_FAMILY_DEFAULT,
|
||||
aiFontSize: AI_CONVERSATION_FONT_SIZE_DEFAULT,
|
||||
});
|
||||
}
|
||||
|
||||
const themeSelectValue = computed(() => {
|
||||
if (editTheme.value === "custom") {
|
||||
return `custom:${editActiveCustomThemeId.value}`;
|
||||
@@ -2805,6 +2875,10 @@ function setDdlOpenMode(value: unknown) {
|
||||
if (value === "dialog" || value === "tab") editDdlOpenMode.value = value;
|
||||
}
|
||||
|
||||
function setDoubleClickStringSelectionMode(value: unknown) {
|
||||
if (value === "content" || value === "word") editDoubleClickStringSelectionMode.value = value;
|
||||
}
|
||||
|
||||
function setIconTheme(value: DesktopIconTheme) {
|
||||
editIconTheme.value = value;
|
||||
}
|
||||
@@ -3144,6 +3218,7 @@ async function revealSettingsSearchTarget(result: SettingsSearchEntry) {
|
||||
async function selectSettingsSearchResult(result: SettingsSearchEntry) {
|
||||
pendingSettingsSearchResult = result;
|
||||
if (result.shortcutId) shortcutSearchQuery.value = result.title;
|
||||
if (result.category === "ai") aiConfigListMode.value = "list";
|
||||
applySettingsSearchRoute(result);
|
||||
settingsSearchQuery.value = "";
|
||||
settingsSearchOpen.value = false;
|
||||
@@ -6077,7 +6152,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-visible pl-1 pr-0 flex flex-col">
|
||||
<div class="min-w-0 min-h-0 flex-1 overflow-visible pl-1 pr-0 flex flex-col">
|
||||
<div class="shrink-0 px-2 pt-1 pb-3">
|
||||
<div ref="settingsSearchInputContainerRef" class="relative">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-4 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -6540,6 +6615,22 @@ onUnmounted(() => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-double-click-string">{{ t("settings.doubleClickStringSelectionMode") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.doubleClickStringSelectionModeDescription") }}</p>
|
||||
</div>
|
||||
<Select :model-value="editDoubleClickStringSelectionMode" @update:model-value="setDoubleClickStringSelectionMode">
|
||||
<SelectTrigger id="editor-double-click-string" class="h-8 w-36 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="content">{{ t("settings.doubleClickStringSelectionModeContent") }}</SelectItem>
|
||||
<SelectItem value="word">{{ t("settings.doubleClickStringSelectionModeWord") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-vim-mode">{{ t("settings.vimMode") }}</Label>
|
||||
@@ -9241,6 +9332,80 @@ LIMIT 100;</pre
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="aiConfigListMode === 'list'" data-settings-search-id="ai-typography" class="space-y-3">
|
||||
<Separator />
|
||||
<div class="settings-item space-y-3 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-medium">{{ t("ai.conversationTypography") }}</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ t("ai.conversationTypographyDescription") }}</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="ghost" class="h-7 shrink-0 gap-1 px-2 text-xs" :disabled="aiTypographySaving || (editAiFontFamily === AI_CONVERSATION_FONT_FAMILY_DEFAULT && Number(editAiFontSize) === AI_CONVERSATION_FONT_SIZE_DEFAULT)" @click="resetAiTypography">
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
{{ t("settings.reset") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<Label>{{ t("ai.conversationFontFamily") }}</Label>
|
||||
<SearchableSelect
|
||||
:model-value="editAiFontFamily"
|
||||
:options="aiFontOptions"
|
||||
:placeholder="t('ai.conversationFontFollowInterface')"
|
||||
:search-placeholder="t('settings.searchFont')"
|
||||
:empty-text="t('settings.noFontsFound')"
|
||||
:loading-text="t('settings.loadingFonts')"
|
||||
:disabled="aiTypographySaving"
|
||||
allow-custom
|
||||
clearable
|
||||
:display-name="displayAiFontFamily"
|
||||
:normalize-custom="normalizeAiConversationFontFamilyInput"
|
||||
:trigger-class="appearanceFontSearchTriggerClass"
|
||||
:trigger-icon-class="appearanceFontSearchTriggerIconClass"
|
||||
content-class="w-[var(--reka-popover-trigger-width)] min-w-[260px]"
|
||||
@update:model-value="onAiFontFamilyChange"
|
||||
@update:open="(open: boolean) => open && loadSystemFontOptions()"
|
||||
>
|
||||
<template #trigger-label="{ label, loading }">
|
||||
<span class="truncate" :style="editAiFontFamily ? { fontFamily: editAiFontFamily } : undefined">
|
||||
{{ loading ? t("settings.loadingFonts") : label }}
|
||||
</span>
|
||||
</template>
|
||||
<template #option-label="{ option, label }">
|
||||
<span class="truncate" :style="fontOptionStyle(option, editAiFontFamily)">{{ label }}</span>
|
||||
</template>
|
||||
<template #custom-option-label="{ value }">
|
||||
<span class="truncate" :style="{ fontFamily: value }">
|
||||
{{ t("settings.useCustomFont", { font: readableFontFamily(value) }) }}
|
||||
</span>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="ai-conversation-font-size">{{ t("ai.conversationFontSize") }}</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="ai-conversation-font-size"
|
||||
v-model.number="editAiFontSize"
|
||||
type="number"
|
||||
:min="AI_CONVERSATION_FONT_SIZE_MIN"
|
||||
:max="AI_CONVERSATION_FONT_SIZE_MAX"
|
||||
step="1"
|
||||
class="h-8 text-xs"
|
||||
:disabled="aiTypographySaving"
|
||||
@change="commitAiFontSize"
|
||||
@keydown.enter="blurAiFontSizeInput"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">px</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
{{ t("ai.conversationFontSizeRange", { min: AI_CONVERSATION_FONT_SIZE_MIN, max: AI_CONVERSATION_FONT_SIZE_MAX }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Turn Limit (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
|
||||
@@ -55,10 +55,11 @@ import { createColumnReferencePayload, tableReferenceInsertText } from "@/lib/ed
|
||||
import { clearRememberedFocusedQueryEditorView, focusedQueryEditorView, queryEditorInsertContext, registerQueryEditorInsertContext, rememberFocusedQueryEditorView, unregisterQueryEditorInsertContext } from "@/lib/editor/focusedQueryEditorView";
|
||||
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
import { structurePeekPanelId } from "@/lib/editor/structurePeekPanel";
|
||||
import { parkEditorNativeSelection, type EditorNativeSelectionPark } from "@/lib/editor/queryEditorNativeSelection";
|
||||
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
|
||||
import QueryEditorContextMenu, { type QueryEditorContextMenuState, type QueryEditorContextMenuActions } from "./QueryEditorContextMenu.vue";
|
||||
|
||||
import { readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { clipboardLineEndings, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
|
||||
import { resolveExecutableSql, type SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
@@ -109,10 +110,10 @@ import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboar
|
||||
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
|
||||
|
||||
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
|
||||
import { createSqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
|
||||
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
|
||||
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
|
||||
import { shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import { shouldUseQueryEditorLargeDocumentMode, shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
||||
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
|
||||
|
||||
@@ -192,6 +193,37 @@ const view = shallowRef<EditorViewType | null>(null);
|
||||
const contextMenuOpen = ref(false);
|
||||
let contextMenuPointerCleanup: (() => void) | null = null;
|
||||
|
||||
// The editor's own context menu is a DOM overlay, so a long selection stays
|
||||
// live underneath it and macOS keeps asking the web view to serialize that
|
||||
// selection while the pointer moves over the menu. Parking the browser
|
||||
// selection for as long as the menu is up is what keeps right click usable on
|
||||
// a few hundred selected lines; see queryEditorNativeSelection.ts.
|
||||
let parkedNativeSelection: EditorNativeSelectionPark | null = null;
|
||||
|
||||
function parkNativeSelectionUnderContextMenu() {
|
||||
const currentView = view.value;
|
||||
if (!currentView) return;
|
||||
parkedNativeSelection?.release();
|
||||
// `clipboardLineEndings` is what the editor registers as its
|
||||
// `clipboardOutputFilter`; passing it here keeps Cmd+C working while the
|
||||
// selection is parked and leaves the copied bytes unchanged.
|
||||
parkedNativeSelection = parkEditorNativeSelection(currentView, { finalizeClipboardText: clipboardLineEndings });
|
||||
}
|
||||
|
||||
function releaseParkedNativeSelection() {
|
||||
parkedNativeSelection?.release();
|
||||
parkedNativeSelection = null;
|
||||
}
|
||||
|
||||
watch(
|
||||
contextMenuOpen,
|
||||
(open) => {
|
||||
if (open) parkNativeSelectionUnderContextMenu();
|
||||
else releaseParkedNativeSelection();
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
|
||||
|
||||
let contextMenuDoc: Text | null = null;
|
||||
@@ -438,8 +470,8 @@ interface EditorGestureEvent extends Event {
|
||||
}
|
||||
|
||||
const codeMirrorRuntime = createQueryEditorCodeMirrorRuntime();
|
||||
const largeDocumentMode = shouldUseQueryEditorLargeDocumentMode(props.modelValue);
|
||||
const fullEditorFeaturesEnabled = () => !largeDocumentMode;
|
||||
const largeDocumentMode = ref(shouldUseQueryEditorLargeDocumentMode(props.modelValue));
|
||||
const boundedEditorAnalysisEnabled = () => largeDocumentMode.value;
|
||||
|
||||
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let editorIsActive = true;
|
||||
@@ -450,7 +482,6 @@ const postCompositionKeyGuard = createQueryEditorPostCompositionKeyGuard();
|
||||
let postCompositionKeyGuardCleanup: (() => void) | null = null;
|
||||
|
||||
function runStatementGutterExtension(): import("@codemirror/state").Extension {
|
||||
if (!fullEditorFeaturesEnabled()) return [];
|
||||
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
|
||||
return shouldShowStatementGutter(showRunButtons) ? (codeMirrorRuntime.buildRunStatementGutterExtension?.() ?? []) : [];
|
||||
}
|
||||
@@ -462,6 +493,7 @@ const statementBoundaries = useQueryEditorStatementBoundaries({
|
||||
view,
|
||||
sqlStatementParameterOptions,
|
||||
runtime: codeMirrorRuntime,
|
||||
driverProfile: () => sqlDriverProfile.value,
|
||||
cache: {
|
||||
get value() {
|
||||
return executableStatementRangeCache;
|
||||
@@ -770,17 +802,21 @@ function schedulePreviewContextRefresh(currentView: EditorViewType) {
|
||||
clearScheduledPreviewContextRefresh();
|
||||
const expectedDoc = currentView.state.doc;
|
||||
const expectedSelection = currentView.state.selection.main;
|
||||
previewContextRefreshTimer = setTimeout(() => {
|
||||
previewContextRefreshTimer = setTimeout(async () => {
|
||||
previewContextRefreshTimer = null;
|
||||
const currentSelection = currentView.state.selection.main;
|
||||
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentSelection.from !== expectedSelection.from || currentSelection.to !== expectedSelection.to || !editorIsActive) return;
|
||||
if (boundedEditorAnalysisEnabled()) {
|
||||
const result = await statementBoundaries.ensureStatementCache(currentView.state);
|
||||
if (!result || !editorIsActive || view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main !== currentSelection) return;
|
||||
}
|
||||
previewContextSql.value = resolvePreviewDmlCandidate();
|
||||
emit("previewChangesAvailable", !!previewContextSql.value);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null {
|
||||
if (!fullEditorFeaturesEnabled() || !props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
|
||||
if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
|
||||
|
||||
const sql = currentEditorDocText(currentView);
|
||||
const selection = currentView.state.selection.main;
|
||||
@@ -867,11 +903,6 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fullEditorFeaturesEnabled()) {
|
||||
contextObjectTarget.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = currentEditorDocText(currentView);
|
||||
if (!props.connectionId || props.database == null) {
|
||||
const candidate = queryTableCandidateAtSqlPosition({
|
||||
@@ -937,7 +968,6 @@ function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
|
||||
// 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。
|
||||
// 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。
|
||||
function resolvePreviewDmlCandidate(position?: number): string {
|
||||
if (!fullEditorFeaturesEnabled()) return "";
|
||||
const currentView = view.value;
|
||||
if (!currentView) return "";
|
||||
const selection = currentView.state.selection.main;
|
||||
@@ -946,8 +976,7 @@ function resolvePreviewDmlCandidate(position?: number): string {
|
||||
return looksLikeDmlStatement(text) ? text : "";
|
||||
}
|
||||
const cursorPos = position ?? selection.head;
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
const cursorRange = executableStatementRangeAtCursor(executableStatementRangeCache, cursorPos);
|
||||
const cursorRange = executableStatementRangeAtPosition(currentView, cursorPos);
|
||||
return cursorRange && looksLikeDmlStatement(cursorRange.sql) ? cursorRange.sql : "";
|
||||
}
|
||||
|
||||
@@ -1080,10 +1109,15 @@ function executableStatementRangeStartingAt(currentView: EditorViewType, lineFro
|
||||
return executableStatementRangeStartingAtLine(executableStatementRangeCache, lineFrom);
|
||||
}
|
||||
|
||||
function executableStatementRangeAtPosition(currentView: EditorViewType, position: number): SqlTextRange | null {
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
return executableStatementRangeAtCursor(executableStatementRangeCache, position);
|
||||
}
|
||||
|
||||
function currentExecutableStatementRange(currentView: EditorViewType): SqlTextRange | null {
|
||||
if (!supportsExecutionTargetPicker(props.databaseType) && props.databaseType !== "mongodb") return null;
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
return executableStatementRangeAtCursor(executableStatementRangeCache, currentView.state.selection.main.head);
|
||||
if (boundedEditorAnalysisEnabled() && executableStatementRangeCache?.doc !== currentView.state.doc) return null;
|
||||
return executableStatementRangeAtPosition(currentView, currentView.state.selection.main.head);
|
||||
}
|
||||
|
||||
function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean {
|
||||
@@ -1520,7 +1554,7 @@ const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnos
|
||||
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
|
||||
maxCompletionTables: MAX_COMPLETION_TABLES,
|
||||
unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
|
||||
fullFeaturesEnabled: fullEditorFeaturesEnabled,
|
||||
boundedAnalysisEnabled: boundedEditorAnalysisEnabled,
|
||||
runtime: {
|
||||
get setSqlDiagnosticsEffect() {
|
||||
return codeMirrorRuntime.setSqlDiagnosticsEffect;
|
||||
@@ -1721,7 +1755,7 @@ function isEditorComposing(currentView: EditorViewType): boolean {
|
||||
return imeCompositionActive || currentView.compositionStarted || currentView.composing;
|
||||
}
|
||||
|
||||
function flushImeComposition() {
|
||||
async function flushImeComposition() {
|
||||
const currentView = view.value;
|
||||
if (!currentView || !pendingImeModelEmit) return;
|
||||
pendingImeModelEmit = false;
|
||||
@@ -1735,7 +1769,8 @@ function flushImeComposition() {
|
||||
documentState.recordSelection(currentView);
|
||||
const fullDoc = currentView.state.doc.toString();
|
||||
const position = currentView.state.selection.main.head;
|
||||
if (shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
|
||||
if (await shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
|
||||
if (view.value !== currentView || currentView.state.selection.main.head !== position || isEditorComposing(currentView)) return;
|
||||
scheduleSqlCompletionStart(currentView);
|
||||
}
|
||||
}
|
||||
@@ -1750,7 +1785,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
view,
|
||||
runtime: codeMirrorRuntime,
|
||||
beforeLoad() {
|
||||
if (fullEditorFeaturesEnabled()) hoverContent.initializeHighlighter();
|
||||
hoverContent.initializeHighlighter();
|
||||
},
|
||||
async prepare(modules) {
|
||||
const initializedRuntime = modules.runtime;
|
||||
@@ -1807,7 +1842,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
currentExecutableStatementRange,
|
||||
executeSqlStatementFromGutter,
|
||||
signatureHelpWindowChars: SQL_SIGNATURE_HELP_WINDOW_CHARS,
|
||||
fullFeaturesEnabled: fullEditorFeaturesEnabled,
|
||||
boundedAnalysisEnabled: boundedEditorAnalysisEnabled,
|
||||
});
|
||||
const initialSettings = settingsStore.editorSettings;
|
||||
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
|
||||
@@ -1815,6 +1850,9 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
await ensureCodeMirrorVim();
|
||||
}
|
||||
const { currentStatementFrameExtension, activeLineHighlighter } = sqlExtensions.createViewDecorations();
|
||||
function updateLargeDocumentMode(currentView: EditorViewType) {
|
||||
largeDocumentMode.value = shouldUseQueryEditorLargeDocumentModeForSize(currentView.state.doc.length, currentView.state.doc.lines);
|
||||
}
|
||||
const editorElement = editorRef.value;
|
||||
if (!editorElement) return;
|
||||
const tooltipParent = editorElement.closest<HTMLElement>("#root")?.querySelector<HTMLElement>("#dbx-query-editor-tooltip-root") ?? editorElement;
|
||||
@@ -1841,23 +1879,22 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
initializedRuntime.runGutterComp.of(runStatementGutterExtension()),
|
||||
initializedRuntime.lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
|
||||
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
|
||||
fullEditorFeaturesEnabled() ? currentStatementFrameExtension : [],
|
||||
currentStatementFrameExtension,
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
initializedRuntime.historyResetComp.of(history()),
|
||||
fullEditorFeaturesEnabled()
|
||||
? foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "cm-foldMarker-svg";
|
||||
span.innerHTML = open
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
return span;
|
||||
},
|
||||
})
|
||||
: [],
|
||||
fullEditorFeaturesEnabled() ? sqlBlockFoldService : [],
|
||||
foldGutter({
|
||||
foldingChanged: (update) => update.transactions.some((transaction) => transaction.effects.some((effect) => !!codeMirrorRuntime.statementBoundariesRefreshEffect && effect.is(codeMirrorRuntime.statementBoundariesRefreshEffect))),
|
||||
markerDOM(open: boolean) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "cm-foldMarker-svg";
|
||||
span.innerHTML = open
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
return span;
|
||||
},
|
||||
}),
|
||||
createSqlBlockFoldService(() => props.databaseType, statementBoundaries.foldRangeForState),
|
||||
drawSelection(),
|
||||
editorClipboardLineEndingsExtension(EditorView),
|
||||
trimmedSelectionLayer(),
|
||||
@@ -1872,12 +1909,13 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
dialect: sqlBehaviorDialect(),
|
||||
language: queryEditorSelectionLanguage(),
|
||||
composing: isEditorComposing(currentView),
|
||||
selectStringContent: settingsStore.editorSettings.doubleClickStringSelectionMode !== "word",
|
||||
}),
|
||||
),
|
||||
),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
fullEditorFeaturesEnabled() ? indentOnInput() : [],
|
||||
fullEditorFeaturesEnabled() ? syntaxHighlighting(defaultHighlightStyle, { fallback: true }) : [],
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
crosshairCursor(),
|
||||
activeLineHighlighter,
|
||||
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
|
||||
@@ -1887,14 +1925,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
|
||||
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
|
||||
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
|
||||
fullEditorFeaturesEnabled()
|
||||
? createSqlUnknownObjectHighlights({
|
||||
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
|
||||
load: loadSqlUnknownObjectSpans,
|
||||
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
|
||||
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
|
||||
})
|
||||
: [],
|
||||
createSqlUnknownObjectHighlights({
|
||||
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
|
||||
load: loadSqlUnknownObjectSpans,
|
||||
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
|
||||
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
|
||||
}),
|
||||
tooltips({ parent: tooltipParent }),
|
||||
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
|
||||
sqlCompletionTheme(EditorView),
|
||||
@@ -1921,17 +1957,15 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
fullEditorFeaturesEnabled() ? hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)) : [],
|
||||
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
|
||||
initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension()),
|
||||
initializedRuntime.diagnosticComp.of(sqlExtensions.buildSqlDiagnosticExtension()),
|
||||
fullEditorFeaturesEnabled()
|
||||
? createInsertValueHintsExtension({
|
||||
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
|
||||
getTableColumns: getInsertValueHintTableColumns,
|
||||
requestTableColumns: requestInsertValueHintTableColumns,
|
||||
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
|
||||
})
|
||||
: [],
|
||||
createInsertValueHintsExtension({
|
||||
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
|
||||
getTableColumns: getInsertValueHintTableColumns,
|
||||
requestTableColumns: requestInsertValueHintTableColumns,
|
||||
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
|
||||
}),
|
||||
initializedRuntime.previewRangeComp.of(sqlExtensions.buildPreviewRangeExtension()),
|
||||
sqlExtensions.buildResultSourceRangeExtension(),
|
||||
Prec.highest(
|
||||
@@ -1975,10 +2009,11 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
rememberFocusedQueryEditorView(update.view);
|
||||
}
|
||||
if (update.docChanged) {
|
||||
completion.invalidateRequests();
|
||||
updateLargeDocumentMode(update.view);
|
||||
searchPanelRef.value?.scheduleDocumentSearchUpdate();
|
||||
if (isEditorComposing(update.view)) {
|
||||
pendingImeModelEmit = true;
|
||||
completion.invalidateRequests();
|
||||
} else {
|
||||
emitModelValue(update.view);
|
||||
invalidateSemanticDiagnosticsForDocumentChange();
|
||||
@@ -2106,7 +2141,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
restoreEditorSelection(props.initialSelection, !props.initialViewport);
|
||||
restoreEditorViewport();
|
||||
performEditorReveal();
|
||||
syncContextMenuState(view.value);
|
||||
if (boundedEditorAnalysisEnabled()) {
|
||||
syncEditorSelectionState(view.value);
|
||||
schedulePreviewContextRefresh(view.value);
|
||||
} else {
|
||||
syncContextMenuState(view.value);
|
||||
}
|
||||
emit("previewChangesAvailable", !!previewContextSql.value);
|
||||
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
|
||||
syncEditorDiagnosticCssVars();
|
||||
@@ -2141,7 +2181,7 @@ onMounted(codeMirrorLifecycle.initialize);
|
||||
// that would no longer be allowed under the new mode.
|
||||
watch(
|
||||
() => settingsStore.editorSettings.completionTriggerMode,
|
||||
(newMode) => {
|
||||
async (newMode) => {
|
||||
if (!view.value || !codeMirrorRuntime.codeMirrorCompletionStatus || !codeMirrorRuntime.codeMirrorCloseCompletion) return;
|
||||
const status = codeMirrorRuntime.codeMirrorCompletionStatus(view.value.state);
|
||||
if (!status || completion.activeOrigin !== "typing") return;
|
||||
@@ -2152,10 +2192,11 @@ watch(
|
||||
}
|
||||
// For other mode changes, re-evaluate the policy.
|
||||
// If the current position would not trigger under the new mode, close.
|
||||
const fullDoc = view.value.state.doc.toString();
|
||||
const position = view.value.state.selection.main.head;
|
||||
if (!shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
|
||||
codeMirrorRuntime.codeMirrorCloseCompletion(view.value);
|
||||
const currentView = view.value;
|
||||
const doc = currentView.state.doc;
|
||||
const position = currentView.state.selection.main.head;
|
||||
if (!(await shouldTriggerSqlCompletionForPosition(doc.toString(), position))) {
|
||||
if (view.value === currentView && currentView.state.doc === doc && currentView.state.selection.main.head === position && settingsStore.editorSettings.completionTriggerMode === newMode) codeMirrorRuntime.codeMirrorCloseCompletion(currentView);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -2514,6 +2555,8 @@ function pauseQueryEditorBackgroundWork() {
|
||||
clearScheduledSemanticDiagnostics();
|
||||
completion.invalidateRequests();
|
||||
unregisterTableReferenceDropListener();
|
||||
completionMetadata.disposeCompletionAnalysis();
|
||||
statementBoundaries.clear();
|
||||
}
|
||||
|
||||
function resumeQueryEditorBackgroundWork() {
|
||||
@@ -2555,6 +2598,7 @@ onBeforeUnmount(() => {
|
||||
pointerInteractions.dispose();
|
||||
objectNavigation.dispose();
|
||||
contextMenuPointerCleanup?.();
|
||||
releaseParkedNativeSelection();
|
||||
postCompositionKeyGuardCleanup?.();
|
||||
postCompositionKeyGuardCleanup = null;
|
||||
batchSelection.dispose();
|
||||
|
||||
@@ -16,13 +16,30 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
const aiAssistantMountApi = vi.hoisted(() => ({
|
||||
conversations: [] as Array<Record<string, unknown>>,
|
||||
runAgentStream: undefined as undefined | ((onEvent: (event: { type: string; delta?: string }) => void) => Promise<string>),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
runAgentStream: async (...args: unknown[]) => {
|
||||
const onEvent = args[2] as (event: { type: string; delta?: string }) => void;
|
||||
return aiAssistantMountApi.runAgentStream?.(onEvent) ?? "";
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const empty = () => Promise.resolve([]);
|
||||
return {
|
||||
...actual,
|
||||
loadAiConversations: empty,
|
||||
loadAiConversations: () => Promise.resolve(aiAssistantMountApi.conversations),
|
||||
loadAiRuns: empty,
|
||||
saveAiConversation: () => Promise.resolve(),
|
||||
readUserSkills: empty,
|
||||
loadAiConfigs: empty,
|
||||
listPlugins: empty,
|
||||
@@ -36,6 +53,8 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
aiAssistantMountApi.conversations = [];
|
||||
aiAssistantMountApi.runAgentStream = undefined;
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
@@ -64,6 +83,104 @@ async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig
|
||||
}
|
||||
|
||||
describe("AiAssistant mount", () => {
|
||||
it("applies custom typography to restored message content and the prompt only", async () => {
|
||||
aiAssistantMountApi.conversations = [
|
||||
{
|
||||
id: "typography-conversation",
|
||||
title: "Typography",
|
||||
connectionName: "",
|
||||
connectionId: "",
|
||||
database: "",
|
||||
messages: [
|
||||
{ role: "user", content: "User message" },
|
||||
{ role: "assistant", content: "### Heading\n\nInline `value`.\n\n```sql\nSELECT 1\n```" },
|
||||
],
|
||||
createdAt: "2026-09-27T00:00:00.000Z",
|
||||
updatedAt: "2026-09-27T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const { container, errors } = await mountPanel(true, undefined, (settings) => {
|
||||
settings.restoreLastConversation = true;
|
||||
settings.editorSettings.aiFontFamily = "Georgia, serif";
|
||||
settings.editorSettings.aiFontSize = 18;
|
||||
});
|
||||
|
||||
const root = container.querySelector<HTMLElement>("[data-ai-assistant-root]");
|
||||
expect(root?.style.getPropertyValue("--dbx-ai-content-font-family")).toBe("Georgia, serif");
|
||||
expect(root?.style.getPropertyValue("--dbx-ai-content-font-size")).toBe("18px");
|
||||
expect(root?.style.getPropertyValue("--dbx-ai-code-font-size")).toBe("18px");
|
||||
expect(container.querySelector("[data-ai-user-message-content]")?.closest(".ai-conversation-text")).not.toBeNull();
|
||||
expect(container.querySelector("[data-ai-assistant-message-content]")?.classList.contains("ai-conversation-text")).toBe(true);
|
||||
expect(container.querySelector(".ai-markdown code")).not.toBeNull();
|
||||
expect(container.querySelector(".ai-code-block")).not.toBeNull();
|
||||
expect(container.querySelector("textarea.ai-conversation-text")).not.toBeNull();
|
||||
expect(container.firstElementChild?.firstElementChild?.classList.contains("ai-conversation-text")).toBe(false);
|
||||
expect(errors.map(String)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the same typography scope while an answer streams and after it completes", async () => {
|
||||
let finishStream!: () => void;
|
||||
const streamPending = new Promise<void>((resolve) => {
|
||||
finishStream = resolve;
|
||||
});
|
||||
aiAssistantMountApi.runAgentStream = async (onEvent) => {
|
||||
onEvent({ type: "text_delta", delta: "**Streaming answer**" });
|
||||
await streamPending;
|
||||
onEvent({ type: "agent_end" });
|
||||
return "Streaming answer";
|
||||
};
|
||||
|
||||
const recommendation = {
|
||||
pluginId: "sample.plugin",
|
||||
pluginName: "Sample",
|
||||
contributionId: "workbench",
|
||||
workbenchId: "cluster",
|
||||
context: { connectionId: "plugin-connection" },
|
||||
items: [{ id: "stream", label: "Stream answer", prompt: "Stream answer" }],
|
||||
};
|
||||
const { container, errors } = await mountPanel(
|
||||
true,
|
||||
{ id: "plugin-connection", name: "Sample", db_type: "plugin", plugin_id: "sample.plugin", host: "localhost", port: 22, username: "", password: "" },
|
||||
(settings) => {
|
||||
settings.aiConfigs = [
|
||||
{
|
||||
id: "custom",
|
||||
name: "Custom",
|
||||
provider: "openai-compatible",
|
||||
apiKey: "test-key",
|
||||
authMethod: "api-key",
|
||||
endpoint: "https://example.com/v1",
|
||||
model: "test-model",
|
||||
apiStyle: "completions",
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
settings.activeModel = { configId: "custom", modelId: "test-model" };
|
||||
settings.editorSettings.aiFontFamily = "Georgia, serif";
|
||||
settings.editorSettings.aiFontSize = 18;
|
||||
},
|
||||
recommendation,
|
||||
);
|
||||
|
||||
container.querySelector<HTMLButtonElement>('button[title="Stream answer"]')!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
const streamingContent = container.querySelector<HTMLElement>("[data-ai-assistant-message-content]");
|
||||
expect(streamingContent?.classList.contains("ai-conversation-text")).toBe(true);
|
||||
expect(streamingContent?.textContent).toContain("Streaming answer");
|
||||
expect(container.querySelector("[data-ai-generation-status]")).not.toBeNull();
|
||||
|
||||
finishStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
const completedContent = container.querySelector<HTMLElement>("[data-ai-assistant-message-content]");
|
||||
expect(completedContent?.classList.contains("ai-conversation-text")).toBe(true);
|
||||
expect(completedContent?.textContent).toContain("Streaming answer");
|
||||
expect(container.querySelector("[data-ai-generation-status]")).toBeNull();
|
||||
expect(errors.map(String)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the real Agent mode and an interactive picker after clicking a plugin recommendation", async () => {
|
||||
const { container, errors } = await mountPanel(
|
||||
true,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import assistantSource from "../AiAssistant.vue?raw";
|
||||
|
||||
describe("AiAssistant typography scope", () => {
|
||||
it("uses one scoped content wrapper for streaming and completed Markdown", () => {
|
||||
expect(assistantSource).toContain("messageRenderer.value.render(msg.content, { streaming: isStreamingMessage(msg) })");
|
||||
expect(assistantSource).toContain('data-ai-assistant-message-content class="ai-conversation-text');
|
||||
expect(assistantSource).toContain('data-ai-user-message-content class="whitespace-pre-wrap"');
|
||||
});
|
||||
|
||||
it("scopes typography to message and input text while preserving monospace code", () => {
|
||||
expect(assistantSource).toContain('data-ai-assistant-root class="flex h-full min-h-0 flex-col overflow-hidden" :style="aiTypographyStyle"');
|
||||
expect(assistantSource).toContain('data-edit-textarea\n v-model="editingContent"');
|
||||
expect(assistantSource).toContain('class="ai-conversation-text w-full resize-none rounded-lg');
|
||||
expect(assistantSource).toContain('class="ai-conversation-text w-full resize-none bg-transparent');
|
||||
expect(assistantSource).toMatch(/\.ai-markdown :deep\(code\)[\s\S]*font-family: ui-monospace, monospace;/);
|
||||
expect(assistantSource).toMatch(/\.ai-code-block \{[\s\S]*font-family: ui-monospace, monospace;/);
|
||||
expect(assistantSource).not.toContain("document.body.style.fontFamily");
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SETTINGS_SEARCH_DEFINITIONS } from "@/lib/settings/settingsSearch";
|
||||
import dialogSource from "../EditorSettingsDialog.vue?raw";
|
||||
|
||||
describe("EditorSettingsDialog AI typography settings", () => {
|
||||
it("exposes the family and size controls through settings search", () => {
|
||||
const definitions = SETTINGS_SEARCH_DEFINITIONS.filter((definition) => definition.id.startsWith("ai-conversation-font-"));
|
||||
|
||||
expect(definitions).toEqual([expect.objectContaining({ id: "ai-conversation-font-family", category: "ai", targetId: "ai-typography" }), expect.objectContaining({ id: "ai-conversation-font-size", category: "ai", targetId: "ai-typography" })]);
|
||||
expect(dialogSource).toContain('data-settings-search-id="ai-typography"');
|
||||
expect(dialogSource).toContain('id="ai-conversation-font-size"');
|
||||
});
|
||||
|
||||
it("persists each control immediately and offers a dedicated reset", () => {
|
||||
expect(dialogSource).toContain('@update:model-value="onAiFontFamilyChange"');
|
||||
expect(dialogSource).toContain('@change="commitAiFontSize"');
|
||||
expect(dialogSource).toContain('@click="resetAiTypography"');
|
||||
expect(dialogSource).toContain("settingsStore.updateEditorSettingsAndPersist(partial)");
|
||||
});
|
||||
|
||||
it("keeps system and custom conversation fonts independent from editor fallbacks", () => {
|
||||
expect(dialogSource).toContain("systemFonts.value.map(aiConversationFontFamilyForName)");
|
||||
expect(dialogSource).toContain(':normalize-custom="normalizeAiConversationFontFamilyInput"');
|
||||
});
|
||||
});
|
||||
+85
-3
@@ -6,12 +6,15 @@ import { createI18n } from "vue-i18n";
|
||||
import { language } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH, QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import type { QueryEditorProps } from "../queryEditorTypes";
|
||||
|
||||
const analysisProbe = vi.hoisted(() => ({
|
||||
documentLengths: [] as number[],
|
||||
documentLineCounts: [] as number[],
|
||||
backgroundDocuments: [] as string[],
|
||||
failAtLength: Number.POSITIVE_INFINITY,
|
||||
failAtLineCount: Number.POSITIVE_INFINITY,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => {
|
||||
@@ -19,13 +22,41 @@ vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
executableStatementRangeCacheForDoc: (...args: Parameters<typeof actual.executableStatementRangeCacheForDoc>) => {
|
||||
if (args[0]?.doc === args[1]) return actual.executableStatementRangeCacheForDoc(...args);
|
||||
analysisProbe.documentLengths.push(args[1].length);
|
||||
if (args[1].length >= analysisProbe.failAtLength) throw new Error("unbounded statement analysis");
|
||||
analysisProbe.documentLineCounts.push(args[1].lines);
|
||||
if (args[1].length >= analysisProbe.failAtLength || args[1].lines >= analysisProbe.failAtLineCount) throw new Error("unbounded statement analysis");
|
||||
return actual.executableStatementRangeCacheForDoc(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/sql/sqlStatementAnalysisWorker", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/sql/executableStatementRangeCache")>("@/lib/sql/executableStatementRangeCache");
|
||||
const { Text } = await import("@codemirror/state");
|
||||
return {
|
||||
createSqlStatementAnalysisWorker: () => {
|
||||
let generation = 0;
|
||||
return {
|
||||
async analyze(request: import("@/lib/sql/sqlStatementAnalysis").SqlStatementAnalysisRequest) {
|
||||
const current = ++generation;
|
||||
analysisProbe.backgroundDocuments.push(request.sql);
|
||||
await Promise.resolve();
|
||||
if (current !== generation) return null;
|
||||
const { doc: _doc, ...result } = actual.executableStatementRangeCacheForDoc(null, Text.of(request.sql.split("\n")), request.databaseType, request.parameterOptions);
|
||||
return result;
|
||||
},
|
||||
cancel() {
|
||||
generation++;
|
||||
},
|
||||
dispose() {
|
||||
generation++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import QueryEditor from "../QueryEditor.vue";
|
||||
|
||||
function oraclePackage(procedureCount: number): string {
|
||||
@@ -50,7 +81,10 @@ const cleanups: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
analysisProbe.documentLengths = [];
|
||||
analysisProbe.documentLineCounts = [];
|
||||
analysisProbe.backgroundDocuments = [];
|
||||
analysisProbe.failAtLength = Number.POSITIVE_INFINITY;
|
||||
analysisProbe.failAtLineCount = Number.POSITIVE_INFINITY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -103,6 +137,40 @@ describe("QueryEditor large document mode", () => {
|
||||
expect(view.state.doc.toString()).toBe(source);
|
||||
});
|
||||
|
||||
it("keeps SQL language features while bounding statement analysis above the line budget", async () => {
|
||||
const lineCount = QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1;
|
||||
const source = Array.from({ length: lineCount }, (_, index) => `SELECT ${index};`).join("\n");
|
||||
expect(source.length).toBeLessThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
|
||||
analysisProbe.failAtLineCount = lineCount;
|
||||
|
||||
const { host, view } = await mountEditor(source);
|
||||
|
||||
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
|
||||
expect(analysisProbe.documentLineCounts).not.toContain(lineCount);
|
||||
expect(view.state.facet(language)).not.toBeNull();
|
||||
expect(analysisProbe.backgroundDocuments).toContain(source);
|
||||
});
|
||||
|
||||
it("enters bounded mode when typing crosses the line budget", async () => {
|
||||
const source = Array.from({ length: QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT }, (_, index) => `SELECT ${index};`).join("\n");
|
||||
const { host, view } = await mountEditor(source);
|
||||
analysisProbe.documentLengths = [];
|
||||
analysisProbe.documentLineCounts = [];
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: view.state.doc.length, insert: "\nS" },
|
||||
selection: { anchor: view.state.doc.length + 2 },
|
||||
userEvent: "input.type",
|
||||
});
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
|
||||
expect(view.state.doc.lines).toBe(QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1);
|
||||
expect(view.state.facet(language)).not.toBeNull();
|
||||
expect(analysisProbe.documentLineCounts).not.toContain(QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1);
|
||||
});
|
||||
|
||||
it("keeps a realistically large package editable and searchable without full-document analysis", async () => {
|
||||
const source = oraclePackage(4_000);
|
||||
expect(source.length).toBeGreaterThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
|
||||
@@ -112,7 +180,7 @@ describe("QueryEditor large document mode", () => {
|
||||
|
||||
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
|
||||
expect(analysisProbe.documentLengths).not.toContain(source.length);
|
||||
expect(view.state.facet(language)).toBeNull();
|
||||
expect(view.state.facet(language)).not.toBeNull();
|
||||
expect(view.state.doc.toString()).toBe(source);
|
||||
expect(view.state.readOnly).toBe(false);
|
||||
expect(view.state.sliceDoc(0, 24)).toBe(source.slice(0, 24));
|
||||
@@ -137,4 +205,18 @@ describe("QueryEditor large document mode", () => {
|
||||
expect(view.state.readOnly).toBe(true);
|
||||
expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
|
||||
});
|
||||
|
||||
it("returns to the ordinary path after shrinking and preserves undo", async () => {
|
||||
const source = Array.from({ length: 2_001 }, (_, index) => `SELECT ${index};`).join("\n");
|
||||
const { host, view } = await mountEditor(source);
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "SELECT 1;" }, userEvent: "input" });
|
||||
await nextTick();
|
||||
expect(host.firstElementChild?.hasAttribute("data-large-document-mode")).toBe(false);
|
||||
expect(view.state.facet(language)).not.toBeNull();
|
||||
const { undo } = await import("@codemirror/commands");
|
||||
expect(undo(view)).toBe(true);
|
||||
await nextTick();
|
||||
expect(view.state.doc.toString()).toBe(source);
|
||||
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
+37
-1
@@ -10,6 +10,7 @@ import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMet
|
||||
import type { QueryEditorProps } from "../queryEditorTypes";
|
||||
import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
|
||||
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis";
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
|
||||
vi.mock("@/lib/backend/api", () => ({}));
|
||||
@@ -31,7 +32,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetadata?: (metadata: ReturnType<typeof useQueryEditorCompletionMetadata>) => void) {
|
||||
const props = reactive<QueryEditorProps>({ modelValue: "SEL", databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "demo", ...overrides });
|
||||
const parent = document.createElement("div");
|
||||
document.body.append(parent);
|
||||
@@ -65,6 +66,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
const connectionStore = store as unknown as Options["connectionStore"];
|
||||
const settings = reactive({ editorSettings: { completionTriggerMode: "positional", snippets: [], sqlFormatter: { keywordCase: "upper", functionCase: "upper" }, autoAliasTables: false, tableCompletionSchemaQualification: "collision", generateSqlQuoteIdentifiers: false } });
|
||||
const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, remoteLatencyBudgetMs: 40, maxCompletionTables: 100, onDemandMinPrefix: 2, semanticCompletionEnabled: false });
|
||||
configureMetadata?.(metadata);
|
||||
const startCompletion = vi.fn(() => true);
|
||||
const runtime: Options["runtime"] = { codeMirrorStartCompletion: startCompletion, codeMirrorInsertCompletionText: insertCompletionText, codeMirrorSnippetCompletion: snippetCompletion, codeMirrorCompletionStatus: () => null, imeCompositionActive: false };
|
||||
const batchSelection = {
|
||||
@@ -102,6 +104,40 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
|
||||
}
|
||||
|
||||
describe("QueryEditor completion provider ownership", () => {
|
||||
it.each(["cursor", "document", "view", "composition", "mode"])("rejects asynchronous analysis after a %s change", async (change) => {
|
||||
let resolve!: (result: SqlCompletionAnalysisResult) => void;
|
||||
const pending = new Promise<SqlCompletionAnalysisResult>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const { currentView, view, runtime, settings, completion, startCompletion } = createHarness({}, (metadata) => {
|
||||
vi.spyOn(metadata, "getEditorSqlCompletionAnalysis").mockReturnValue(pending);
|
||||
});
|
||||
completion.scheduleDeferredCompletionTrigger(currentView, "L", "");
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(startCompletion).not.toHaveBeenCalled();
|
||||
if (change === "cursor") currentView.dispatch({ selection: { anchor: 0 } });
|
||||
if (change === "document") currentView.dispatch({ changes: { from: 0, insert: " " } });
|
||||
if (change === "view") view.value = null;
|
||||
if (change === "composition") runtime.imeCompositionActive = true;
|
||||
if (change === "mode") settings.editorSettings.completionTriggerMode = "manual";
|
||||
resolve(analyzeSqlCompletion({ sql: "SEL", cursor: 3, databaseType: "mysql", semanticCompletionEnabled: false }));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(startCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a provider result invalidated while background analysis is running", async () => {
|
||||
let resolve!: (result: SqlCompletionAnalysisResult) => void;
|
||||
const pending = new Promise<SqlCompletionAnalysisResult>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const { provide, completion } = createHarness({}, (metadata) => {
|
||||
vi.spyOn(metadata, "getEditorSqlCompletionAnalysis").mockReturnValue(pending);
|
||||
});
|
||||
const result = provide();
|
||||
completion.invalidateRequests();
|
||||
resolve(analyzeSqlCompletion({ sql: "SEL", cursor: 3, databaseType: "mysql", semanticCompletionEnabled: false }));
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
it("keeps manual completion available while automatic completion is disabled", async () => {
|
||||
const { settings, provide, completion, currentView, startCompletion } = createHarness({ database: undefined });
|
||||
settings.editorSettings.completionTriggerMode = "manual";
|
||||
|
||||
+43
-3
@@ -20,7 +20,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createHarness() {
|
||||
function createHarness(doc = "SELECT 1;\nSELECT 2;") {
|
||||
const view = shallowRef<EditorView | null>(null);
|
||||
const cache = { value: null as ExecutableStatementRangeCache | null };
|
||||
const runtime = createQueryEditorCodeMirrorRuntime();
|
||||
@@ -34,7 +34,7 @@ function createHarness() {
|
||||
});
|
||||
const parent = document.createElement("div");
|
||||
document.body.append(parent);
|
||||
const currentView = new EditorView({ parent, state: EditorState.create({ doc: "SELECT 1;\nSELECT 2;", extensions: [boundaries.createTrackingPlugin(ViewPlugin)] }) });
|
||||
const currentView = new EditorView({ parent, state: EditorState.create({ doc, extensions: [boundaries.createTrackingPlugin(ViewPlugin)] }) });
|
||||
view.value = currentView;
|
||||
cleanups.push(() => {
|
||||
currentView.destroy();
|
||||
@@ -87,6 +87,19 @@ describe("QueryEditor shared statement boundaries", () => {
|
||||
expect(boundaries.statementBoundariesForState(currentView.state).doc).toBe(currentView.state.doc);
|
||||
});
|
||||
|
||||
it("clears a pending refresh when the editor is paused", async () => {
|
||||
const { currentView, cache, boundaries } = createHarness();
|
||||
boundaries.statementBoundariesForState(currentView.state);
|
||||
currentView.dispatch({ changes: { from: 0, insert: " " } });
|
||||
|
||||
boundaries.clear();
|
||||
const dispatch = vi.spyOn(currentView, "dispatch");
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(cache.value).toBeNull();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["destroyed", "replaced", "detached"])("does not refresh an editor that was %s", async (reason) => {
|
||||
const { currentView, view, parent, boundaries, cache } = createHarness();
|
||||
boundaries.statementBoundariesForState(currentView.state);
|
||||
@@ -98,6 +111,33 @@ describe("QueryEditor shared statement boundaries", () => {
|
||||
const dispatch = vi.spyOn(currentView, "dispatch");
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(cache.value).toBe(previousCache);
|
||||
expect(cache.value).toBe(reason === "destroyed" ? null : previousCache);
|
||||
});
|
||||
|
||||
it("maps large-document folds while an exact background rebuild is pending", async () => {
|
||||
const source = `BEGIN\n${"SELECT 1;\n".repeat(2_001)}END;`;
|
||||
const { currentView, boundaries } = createHarness(source);
|
||||
const initial = boundaries.statementBoundariesForState(currentView.state);
|
||||
expect(initial.fresh).toBe(false);
|
||||
await boundaries.ensureStatementCache(currentView.state);
|
||||
const original = boundaries.foldRangeForState(currentView.state, 0);
|
||||
expect(original).toEqual({ from: 5, to: source.indexOf("END;") });
|
||||
currentView.dispatch({ changes: { from: 0, insert: "-- prefix\n" } });
|
||||
expect(boundaries.statementBoundariesForState(currentView.state).fresh).toBe(false);
|
||||
expect(boundaries.foldRangeForState(currentView.state, 10)).toEqual({ from: original!.from + 10, to: original!.to + 10 });
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(boundaries.statementBoundariesForState(currentView.state).fresh).toBe(true);
|
||||
});
|
||||
|
||||
it("never installs a background result for an edited or paused document", async () => {
|
||||
const { currentView, boundaries, cache } = createHarness("SELECT 1;\n".repeat(2_001));
|
||||
const pending = boundaries.ensureStatementCache(currentView.state);
|
||||
currentView.dispatch({ changes: { from: 0, insert: " " } });
|
||||
await expect(pending).resolves.toBeNull();
|
||||
expect(cache.value).toBeNull();
|
||||
const paused = boundaries.ensureStatementCache(currentView.state);
|
||||
boundaries.clear();
|
||||
await expect(paused).resolves.toBeNull();
|
||||
expect(cache.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { syntaxTreeAvailable } from "@codemirror/language";
|
||||
import { type QueryCompletionOption } from "./useQueryEditorBatchSelection";
|
||||
import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeCacheForDoc, statementGutterStartIndexHasStartAt } from "@/lib/sql/executableStatementRangeCache";
|
||||
@@ -50,11 +51,11 @@ interface QueryEditorSqlExtensionsOptions {
|
||||
currentExecutableStatementRange: (view: EditorViewType) => SqlTextRange | null;
|
||||
executeSqlStatementFromGutter: (view: EditorViewType, line: { from: number; to: number }, event: Event) => boolean;
|
||||
signatureHelpWindowChars: number;
|
||||
fullFeaturesEnabled: () => boolean;
|
||||
boundedAnalysisEnabled: () => boolean;
|
||||
}
|
||||
|
||||
export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensionsOptions) {
|
||||
const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter, fullFeaturesEnabled } = options;
|
||||
const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter, boundedAnalysisEnabled } = options;
|
||||
const { EditorView, Decoration, StateField, StateEffect, GutterMarker, RangeSet, lineNumberMarkers, gutter, showTooltip, autocompletion, ViewPlugin, highlightingFor, syntaxTree, langSql, Prec, EditorState, ensureSyntaxTree, layer, RectangleMarker } = options.modules;
|
||||
const { provideSqlCompletions } = options.completion;
|
||||
const { renderBatchColumnSelectionActionMarker, renderBatchColumnSelectionCheckbox } = batchSelection;
|
||||
@@ -74,7 +75,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
});
|
||||
|
||||
codeMirrorRuntime.buildSqlDiagnosticExtension = () => {
|
||||
if (!fullFeaturesEnabled()) return [];
|
||||
const diagnosticEffect = codeMirrorRuntime.setSqlDiagnosticsEffect;
|
||||
const buildDecorations = (state: import("@codemirror/state").EditorState) => {
|
||||
const errorDecorations = sqlErrorDecorationRange(state).map((range) =>
|
||||
@@ -243,7 +243,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
codeMirrorRuntime.setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
|
||||
|
||||
codeMirrorRuntime.buildRunStatementGutterExtension = () => {
|
||||
if (!fullFeaturesEnabled()) return [];
|
||||
const effectType = codeMirrorRuntime.setStatementExecutionMarkersEffect!;
|
||||
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
|
||||
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
|
||||
@@ -294,7 +293,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
};
|
||||
|
||||
codeMirrorRuntime.buildSqlSignatureExtension = () => {
|
||||
if (!fullFeaturesEnabled()) return [];
|
||||
return showTooltip.compute(["doc", "selection"], (currentState) => {
|
||||
const cursor = currentState.selection.main.head;
|
||||
// Signature detection only scans backward from the cursor, so window the
|
||||
@@ -313,7 +311,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
};
|
||||
|
||||
codeMirrorRuntime.buildSqlCompletionExtension = () => {
|
||||
if (!fullFeaturesEnabled()) return [];
|
||||
return autocompletion({
|
||||
activateOnTyping: true,
|
||||
defaultKeymap: false,
|
||||
@@ -333,11 +330,9 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });
|
||||
|
||||
codeMirrorRuntime.buildSqlLanguageExtension = () => [
|
||||
fullFeaturesEnabled()
|
||||
? langSql.sql({
|
||||
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
|
||||
})
|
||||
: [],
|
||||
langSql.sql({
|
||||
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
|
||||
}),
|
||||
// Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the
|
||||
// comment marker that toggleLineComment reads from language data.
|
||||
Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])),
|
||||
@@ -355,7 +350,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
const refreshSqlSemanticHighlightEffect = StateEffect.define<null>();
|
||||
|
||||
codeMirrorRuntime.buildSqlSemanticHighlightExtension = () => {
|
||||
if (!fullFeaturesEnabled()) return [];
|
||||
return [
|
||||
createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }),
|
||||
ViewPlugin.fromClass(
|
||||
@@ -422,7 +416,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
}
|
||||
|
||||
const sql = this.cachedSql;
|
||||
const shouldPrewarmFullDocument = this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
|
||||
const shouldPrewarmFullDocument = !boundedAnalysisEnabled() && this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
|
||||
const windows: Array<{
|
||||
from: number;
|
||||
to: number;
|
||||
@@ -451,7 +445,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
|
||||
if (pendingWindows.length > 0) {
|
||||
const requestedTo = Math.max(...pendingWindows.map((window) => window.to));
|
||||
const tree = ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
|
||||
const tree = boundedAnalysisEnabled() ? (syntaxTreeAvailable(currentView.state, requestedTo) ? syntaxTree(currentView.state) : null) : ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
|
||||
if (!tree) {
|
||||
// The Lezer parse has not reached the pending windows yet (long
|
||||
// documents). Keep the decorations that are still valid and let
|
||||
@@ -508,12 +502,13 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const boundaries = statementBoundariesForState(view.state);
|
||||
if (boundaries && !boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
|
||||
if (!boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
|
||||
// Typing in progress: reuse the ChangeSet-shifted range instead of
|
||||
// re-parsing the whole document. The debounced refresh rebuilds and
|
||||
// repaints the exact frame once typing pauses.
|
||||
return { from: boundaries.frameRange.from, to: currentStatementFrameTo(view, { from: boundaries.frameRange.from, to: boundaries.frameRange.to, sql: "" }) };
|
||||
}
|
||||
if (boundedAnalysisEnabled() && !boundaries.fresh) return null;
|
||||
let range = currentExecutableStatementRange(view);
|
||||
if (!range) {
|
||||
const cursorLine = view.state.doc.lineAt(cursorPos);
|
||||
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
buildSqlCompletionItemsFromContext,
|
||||
buildPostgresSequenceLiteralCompletionItems,
|
||||
getSqlCompletionContext,
|
||||
getPostgresSequenceLiteralCompletionContext,
|
||||
getSqlCompletionResultValidFor,
|
||||
isSqlCompletionSuppressedContext,
|
||||
isSqlLikeCompletionStatement,
|
||||
prepareSqlCompletionReplacement,
|
||||
recordCompletionSelection,
|
||||
@@ -21,8 +19,6 @@ import {
|
||||
} from "@/lib/sql/sqlCompletion";
|
||||
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts, type SqlCompletionTriggerOrigin } from "@/lib/sql/sqlCompletionTriggerPolicy";
|
||||
import { driverProfileHasCompletionCandidates } from "@/lib/database/driverProfileExtensions";
|
||||
import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion";
|
||||
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
|
||||
import { buildElasticsearchCompletionItemsFromContext, elasticsearchCompletionNeedsFields, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion } from "@/lib/mongo/mongoCompletion";
|
||||
import { buildSoqlCompletionItems, getSoqlCompletionContext, getSoqlCompletionResultValidFor, resolveSoqlFieldCandidates, resolveSoqlValueField, shouldAutoOpenSoqlCompletion, soqlCompletionNeedsObjects, type SoqlCompletionField, type SoqlCompletionObject } from "@/lib/soql/soqlCompletion";
|
||||
@@ -33,7 +29,6 @@ import {
|
||||
resolveSqlCompletionSchemaLookupDatabase,
|
||||
resolveSqlCompletionScope,
|
||||
resolveSqlCompletionTableLookupTarget,
|
||||
resolveSqlServerUseDatabaseCompletion,
|
||||
sqlServerUseCompletionDatabaseNames,
|
||||
sqlServerUseDatabaseBeforeCursor,
|
||||
} from "@/lib/sql/sqlCompletionLookupTarget";
|
||||
@@ -42,7 +37,7 @@ import { completionReplacementTo, shouldResolveSqlColumnCompletion } from "@/lib
|
||||
import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation";
|
||||
import { supportsDatabaseNameCompletion } from "@/lib/database/databaseFeatureSupport";
|
||||
import { sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
|
||||
import { oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
|
||||
import { isOracleCompletionDatabase } from "@/lib/sql/oracleCompletionSession";
|
||||
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument } from "@/lib/redis/redisCompletion";
|
||||
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sql/sqlCompletion";
|
||||
@@ -87,6 +82,8 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
supportsDatabaseSchemaQualifierCompletion,
|
||||
sqlCompletionDialectOptions,
|
||||
getEditorSqlCompletionContext,
|
||||
getEditorSqlCompletionAnalysis,
|
||||
cancelEditorSqlCompletionAnalysis,
|
||||
supportsDatabaseQualifierCompletion,
|
||||
completionObjectsForScope,
|
||||
usesOracleSessionCompletionColumns,
|
||||
@@ -119,7 +116,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
const COMPLETION_TRIGGER_DEFER_DELAY_MS = options.triggerDeferDelayMs;
|
||||
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
|
||||
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = options.onDemandTableLimit;
|
||||
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
|
||||
let analyzedCtes: { sql: string; definitions: ReturnType<typeof extractCteDefinitions> } | null = null;
|
||||
|
||||
const snippetDatabaseType = computed(() => {
|
||||
const connection = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined;
|
||||
@@ -573,7 +570,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
|
||||
if (props.database && mongoCompletionNeedsCollections(completionContext.mode)) {
|
||||
try {
|
||||
collections = await connectionStore.listMongoCompletionCollections(props.connectionId, props.database);
|
||||
collections = await connectionStore.listMongoCompletionCollections(props.connectionId, completionContext.database ?? props.database);
|
||||
} catch {
|
||||
collections = [];
|
||||
}
|
||||
@@ -581,7 +578,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
|
||||
if (props.database && mongoCompletionNeedsFields(completionContext.mode) && completionContext.collection) {
|
||||
try {
|
||||
fields = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, completionContext.collection);
|
||||
fields = await connectionStore.listMongoCompletionFields(props.connectionId, completionContext.database ?? props.database, completionContext.collection);
|
||||
} catch {
|
||||
fields = [];
|
||||
}
|
||||
@@ -679,26 +676,26 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
return provideSoqlCompletions(currentState, position, explicit);
|
||||
}
|
||||
const hasDatabase = props.database != null;
|
||||
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
|
||||
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
|
||||
|
||||
const epoch = ++completionEpoch;
|
||||
context.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (epoch !== completionEpoch) return;
|
||||
completionEpoch++;
|
||||
cancelEditorSqlCompletionAnalysis();
|
||||
},
|
||||
{ onDocChange: true },
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. Suppressed context (comment / string literal) rejects everything, including explicit.
|
||||
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: currentState }) && !sequenceLiteralContext) return null;
|
||||
|
||||
// 2. Determine completion origin (session-level marker).
|
||||
activeCompletionOrigin = originForSqlCompletionProvider(activeCompletionOrigin, context.explicit);
|
||||
const origin = activeCompletionOrigin;
|
||||
|
||||
// 3. Explicit (manual shortcut) -> always proceed. No mode gating.
|
||||
// 4. For typing sessions, apply mode gating with lazy fact computation.
|
||||
const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({
|
||||
sql: fullDoc,
|
||||
cursor: position,
|
||||
databaseType: props.databaseType,
|
||||
});
|
||||
if (origin !== "explicit" && settingsStore.editorSettings.completionTriggerMode === "manual") return null;
|
||||
const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentState);
|
||||
if (!analysis || context.aborted || epoch !== completionEpoch) return null;
|
||||
analyzedCtes = { sql: fullDoc, definitions: analysis.cteDefinitions };
|
||||
const { sequenceLiteralContext, databaseLinkContext, useDatabaseCompletion } = analysis;
|
||||
if (analysis.suppressed && !sequenceLiteralContext) return null;
|
||||
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
|
||||
|
||||
if (origin !== "explicit") {
|
||||
@@ -709,7 +706,8 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
|
||||
// require-prefix: only compute local facts (no positionalEligible).
|
||||
if (mode === "require-prefix") {
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? analysis.completionContext;
|
||||
if (!ctx || epoch !== completionEpoch) return null;
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin,
|
||||
@@ -722,9 +720,10 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
|
||||
// positional: compute positionalEligible (lazy).
|
||||
if (mode === "positional") {
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? analysis?.completionContext;
|
||||
if (!ctx || epoch !== completionEpoch) return null;
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const positionalEligible = !!databaseLinkContext || !!sequenceLiteralContext || !!analysis?.positionalEligible;
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin,
|
||||
hasIdentifierPrefix: ctx.prefix.length > 0,
|
||||
@@ -792,9 +791,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
return buildCompletionResult(buildPostgresSequenceLiteralCompletionItems(sequenceLiteralContext, sequences), sequenceLiteralContext.from, undefined, sequenceLiteralContext.prefix);
|
||||
}
|
||||
|
||||
const legacyCompletionContext = getEditorSqlCompletionContext(fullDoc, position);
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, sqlCompletionDialectOptions()) : null;
|
||||
let completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
|
||||
let completionContext = analysis.completionContext;
|
||||
|
||||
if (!hasDatabase) {
|
||||
const items = buildSqlCompletionItemsFromContext(completionContext, {
|
||||
@@ -960,10 +957,11 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
clearDeferredCompletionTrigger();
|
||||
const expectedDoc = currentView.state.doc;
|
||||
const expectedPosition = currentView.state.selection.main.head;
|
||||
deferredCompletionTriggerTimer = setTimeout(() => {
|
||||
deferredCompletionTriggerTimer = setTimeout(async () => {
|
||||
deferredCompletionTriggerTimer = null;
|
||||
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main.head !== expectedPosition || isEditorComposing(currentView)) return;
|
||||
if (shouldStartSqlCompletionAfterInput(insertedText, removedText, currentView)) {
|
||||
if (await shouldStartSqlCompletionAfterInput(insertedText, removedText, currentView)) {
|
||||
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main.head !== expectedPosition || isEditorComposing(currentView)) return;
|
||||
scheduleSqlCompletionStart(currentView);
|
||||
}
|
||||
}, COMPLETION_TRIGGER_DEFER_DELAY_MS);
|
||||
@@ -973,47 +971,27 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
* Returns true when the current SQL position should trigger completion under the active trigger mode.
|
||||
* Used by flushImeComposition and shouldStartSqlCompletionAfterInput.
|
||||
*/
|
||||
function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): boolean {
|
||||
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
|
||||
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
|
||||
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: view.value?.state }) && !sequenceLiteralContext) return false;
|
||||
async function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): Promise<boolean> {
|
||||
const mode = settingsStore.editorSettings.completionTriggerMode;
|
||||
if (mode === "manual") return false;
|
||||
|
||||
const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({
|
||||
sql: fullDoc,
|
||||
cursor: position,
|
||||
databaseType: props.databaseType,
|
||||
});
|
||||
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
|
||||
|
||||
if (mode === "require-prefix") {
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin: "typing",
|
||||
hasIdentifierPrefix: ctx.prefix.length > 0,
|
||||
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
|
||||
useDatabasePrefix,
|
||||
};
|
||||
return shouldAllowSqlCompletionTrigger(mode, facts);
|
||||
}
|
||||
|
||||
// positional
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const currentView = view.value;
|
||||
const expectedDoc = currentView?.state.doc;
|
||||
const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentView?.state);
|
||||
if (!analysis || view.value !== currentView || currentView?.state.doc !== expectedDoc || settingsStore.editorSettings.completionTriggerMode !== mode) return false;
|
||||
const { sequenceLiteralContext, databaseLinkContext, useDatabaseCompletion } = analysis;
|
||||
if (analysis.suppressed && !sequenceLiteralContext) return false;
|
||||
const context = databaseLinkContext ?? sequenceLiteralContext ?? analysis.completionContext;
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
return shouldAllowSqlCompletionTrigger(mode, {
|
||||
origin: "typing",
|
||||
hasIdentifierPrefix: ctx.prefix.length > 0,
|
||||
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
|
||||
useDatabasePrefix,
|
||||
positionalEligible,
|
||||
};
|
||||
return shouldAllowSqlCompletionTrigger(mode, facts);
|
||||
hasIdentifierPrefix: context.prefix.length > 0,
|
||||
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in context ? context.schema != null : "qualifier" in context && context.qualifier != null)),
|
||||
useDatabasePrefix: useDatabaseCompletion?.prefix ?? null,
|
||||
positionalEligible: analysis.positionalEligible,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): boolean {
|
||||
async function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): Promise<boolean> {
|
||||
const position = currentView.state.selection.main.head;
|
||||
const fullDoc = currentView.state.doc.toString();
|
||||
|
||||
@@ -1122,7 +1100,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
}
|
||||
}
|
||||
|
||||
const cteDefs = extractCteDefinitions(fullDoc);
|
||||
const cteDefs = analyzedCtes?.sql === fullDoc ? analyzedCtes.definitions : extractCteDefinitions(fullDoc);
|
||||
for (const refTable of completionContext.referencedTables) {
|
||||
if (refTable.columns?.length) {
|
||||
columnsByTable.set(
|
||||
@@ -1541,7 +1519,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
}
|
||||
|
||||
// Populate CTE columns from parsed definitions
|
||||
const cteDefs = extractCteDefinitions(fullDoc);
|
||||
const cteDefs = analyzedCtes?.sql === fullDoc ? analyzedCtes.definitions : extractCteDefinitions(fullDoc);
|
||||
for (const refTable of refs) {
|
||||
if (refTable.columns) continue;
|
||||
const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase());
|
||||
@@ -1676,11 +1654,14 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
return completionContext.suggestTables || completionContext.exclusiveTableSuggestions;
|
||||
}
|
||||
|
||||
function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType<typeof getSqlCompletionContext>) {
|
||||
async function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType<typeof getSqlCompletionContext>) {
|
||||
const currentView = view.value;
|
||||
if (!currentView || runtime.codeMirrorCompletionStatus?.(currentView.state) !== "active") return;
|
||||
if (currentView.state.doc.toString() !== fullDoc || currentView.state.selection.main.head !== position) return;
|
||||
const currentContext = getSqlCompletionContext(fullDoc, position);
|
||||
const doc = currentView.state.doc;
|
||||
const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentView.state);
|
||||
if (!analysis || view.value !== currentView || currentView.state.doc !== doc || currentView.state.selection.main.head !== position) return;
|
||||
const currentContext = analysis.completionContext;
|
||||
if (currentContext.prefix !== completionContext.prefix || currentContext.contextKind !== completionContext.contextKind) return;
|
||||
scheduleSqlCompletionStart(currentView);
|
||||
}
|
||||
@@ -1696,6 +1677,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
clearDeferredCompletionTrigger,
|
||||
invalidateRequests() {
|
||||
completionEpoch++;
|
||||
cancelEditorSqlCompletionAnalysis();
|
||||
},
|
||||
get activeOrigin() {
|
||||
return activeCompletionOrigin;
|
||||
|
||||
@@ -5,6 +5,9 @@ import { COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore";
|
||||
import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
|
||||
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
|
||||
import type { SqlSemanticModel } from "@/lib/sql/semantic/types";
|
||||
import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis";
|
||||
import { createSqlCompletionAnalysisWorker } from "@/lib/sql/sqlCompletionAnalysisWorker";
|
||||
import { shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
|
||||
import { mergeSqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
|
||||
import { requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
@@ -59,6 +62,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
||||
|
||||
const loadedColumnsByTable = new Set<string>();
|
||||
const completionAnalysisWorker = createSqlCompletionAnalysisWorker();
|
||||
|
||||
function sqlCompletionDialectOptions() {
|
||||
return {
|
||||
@@ -102,6 +106,64 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
return context;
|
||||
}
|
||||
|
||||
let editorCompletionAnalysisCache: {
|
||||
doc: Text;
|
||||
editorState: EditorState;
|
||||
position: number;
|
||||
databaseType: DatabaseType | undefined;
|
||||
dialect: QueryEditorProps["dialect"];
|
||||
driverProfile: string | undefined;
|
||||
syntaxDialect: QueryEditorProps["syntaxDialect"];
|
||||
result: Promise<SqlCompletionAnalysisResult | null>;
|
||||
} | null = null;
|
||||
|
||||
function getEditorSqlCompletionAnalysis(sql: string, position: number, editorState = view.value?.state): Promise<SqlCompletionAnalysisResult | null> {
|
||||
const dialect = sqlBehaviorDialect();
|
||||
const doc = editorState?.doc;
|
||||
const driverProfile = props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined;
|
||||
if (
|
||||
doc &&
|
||||
editorCompletionAnalysisCache?.doc === doc &&
|
||||
editorCompletionAnalysisCache.editorState === editorState &&
|
||||
editorCompletionAnalysisCache.position === position &&
|
||||
editorCompletionAnalysisCache.databaseType === props.databaseType &&
|
||||
editorCompletionAnalysisCache.dialect === dialect &&
|
||||
editorCompletionAnalysisCache.driverProfile === driverProfile &&
|
||||
editorCompletionAnalysisCache.syntaxDialect === props.syntaxDialect
|
||||
) {
|
||||
return editorCompletionAnalysisCache.result;
|
||||
}
|
||||
|
||||
const request = {
|
||||
sql,
|
||||
cursor: position,
|
||||
databaseType: props.databaseType,
|
||||
dialect,
|
||||
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
|
||||
syntaxDialect: props.syntaxDialect,
|
||||
driverProfile,
|
||||
};
|
||||
const result = editorState && shouldUseQueryEditorLargeDocumentModeForSize(editorState.doc.length, editorState.doc.lines) ? completionAnalysisWorker.analyze(request, editorState) : Promise.resolve(analyzeSqlCompletion(request, editorState));
|
||||
if (doc && editorState) {
|
||||
editorCompletionAnalysisCache = {
|
||||
doc,
|
||||
editorState,
|
||||
position,
|
||||
databaseType: props.databaseType,
|
||||
dialect,
|
||||
driverProfile,
|
||||
syntaxDialect: props.syntaxDialect,
|
||||
result,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cancelEditorSqlCompletionAnalysis() {
|
||||
editorCompletionAnalysisCache = null;
|
||||
completionAnalysisWorker.cancel();
|
||||
}
|
||||
|
||||
// CTE definition metadata is cursor-independent, but hover/ctrl-click models still carry the
|
||||
// position; memoize per (Text node, position, dialect, state) so hover and click at the same
|
||||
// position share one parse. Any edit swaps the Text node, invalidating the cache for free.
|
||||
@@ -541,6 +603,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
}
|
||||
|
||||
function refreshCompletionCache() {
|
||||
cancelEditorSqlCompletionAnalysis();
|
||||
cachedTables = [];
|
||||
cachedCompletionObjectsByScope.clear();
|
||||
cachedColumnsByTable.clear();
|
||||
@@ -553,6 +616,9 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
|
||||
return {
|
||||
sqlCompletionDialectOptions,
|
||||
getEditorSqlCompletionContext,
|
||||
getEditorSqlCompletionAnalysis,
|
||||
cancelEditorSqlCompletionAnalysis,
|
||||
disposeCompletionAnalysis: completionAnalysisWorker.dispose,
|
||||
ensureColumnsForTable,
|
||||
cachedColumnsByTable,
|
||||
completionCacheKey,
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { QueryEditorProps, CompletionMetadataScope } from "./queryEditorTypes";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||||
import { createSqlDiagnosticAnalysisWorker } from "@/lib/sql/sqlDiagnosticAnalysisWorker";
|
||||
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeCacheForDoc, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
import { editorRootSelection } from "@/lib/editor/queryEditorNativeSelection";
|
||||
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
|
||||
import { mergeSqlSemanticReferenceAnalysis } from "@/lib/sql/semantic/references";
|
||||
import { sqlServerUseDatabaseBeforeCursor } from "@/lib/sql/sqlCompletionLookupTarget";
|
||||
@@ -69,12 +71,13 @@ interface QueryEditorDiagnosticsOptions {
|
||||
semanticCompletionEnabled: boolean;
|
||||
maxCompletionTables: number;
|
||||
unknownObjectHighlightEnabled: boolean;
|
||||
fullFeaturesEnabled?: () => boolean;
|
||||
boundedAnalysisEnabled?: () => boolean;
|
||||
runtime: QueryEditorDiagnosticsRuntime;
|
||||
metadata: QueryEditorDiagnosticMetadata;
|
||||
}
|
||||
|
||||
export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions) {
|
||||
const diagnosticAnalysisWorker = createSqlDiagnosticAnalysisWorker();
|
||||
const { props, view, settingsStore, connectionStore, sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, runtime, metadata } = options;
|
||||
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
|
||||
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
|
||||
@@ -137,15 +140,6 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors CodeMirror's own root handling: shadow roots only expose
|
||||
// `getSelection` on some browsers, otherwise the owner document holds it.
|
||||
function editorRootSelection(currentView: EditorViewType): Selection | null {
|
||||
const root = currentView.root as unknown as ShadowRoot & { getSelection?: () => Selection | null };
|
||||
if (root.nodeType !== 11) return (root as unknown as Document).getSelection();
|
||||
if (typeof root.getSelection === "function") return root.getSelection() ?? null;
|
||||
return root.ownerDocument?.getSelection() ?? null;
|
||||
}
|
||||
|
||||
// See queryEditorDiagnosticCaretAnchor.ts for why the browser caret needs re-anchoring.
|
||||
function reanchorCaretAfterDiagnostics(currentView: EditorViewType) {
|
||||
const selection = currentView.state.selection;
|
||||
@@ -189,6 +183,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
}
|
||||
|
||||
function clearScheduledSemanticDiagnostics() {
|
||||
diagnosticAnalysisWorker.dispose();
|
||||
semanticDiagnosticRunId++;
|
||||
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
|
||||
semanticDiagnosticTimer = null;
|
||||
@@ -196,12 +191,13 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
}
|
||||
|
||||
function invalidateSemanticDiagnosticsForDocumentChange() {
|
||||
diagnosticAnalysisWorker.cancel();
|
||||
semanticDiagnosticRunId++;
|
||||
semanticDiagnostics = [];
|
||||
}
|
||||
|
||||
function shouldSkipSqlSemanticDiagnostics() {
|
||||
return options.fullFeaturesEnabled?.() === false || props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
|
||||
return props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
|
||||
}
|
||||
|
||||
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
|
||||
@@ -330,7 +326,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
return kind === "cte" || kind === "subquery" || kind === "table_function";
|
||||
}
|
||||
|
||||
async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boolean } = {}) {
|
||||
async function refreshSemanticDiagnostics(refreshOptions: { preserveOutsideRanges?: boolean } = {}) {
|
||||
const currentView = view.value;
|
||||
const runId = ++semanticDiagnosticRunId;
|
||||
if (!currentView || !props.connectionId || props.database == null) {
|
||||
@@ -357,7 +353,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
// Redis has no SQL semantics; run command-name / arity / quote / danger checks instead.
|
||||
if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) {
|
||||
scheduleSemanticDiagnostics(900, {
|
||||
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||
preserveOutsideRanges: refreshOptions.preserveOutsideRanges,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -368,35 +364,38 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
||||
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
|
||||
const backgroundAnalysis = options.boundedAnalysisEnabled?.() === true;
|
||||
const prepared = backgroundAnalysis ? (await diagnosticAnalysisWorker.analyze({ sql, cursor: currentView.state.selection.main.head, databaseType: props.databaseType, driverProfile: sqlDriverProfile.value, parameterOptions: sqlStatementParameterOptions(), visibleRanges }))?.document : undefined;
|
||||
if (runId !== semanticDiagnosticRunId || (backgroundAnalysis && !prepared)) return;
|
||||
if (!(prepared?.shouldRun ?? shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType }))) {
|
||||
scheduleSemanticDiagnostics(1200, {
|
||||
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||
preserveOutsideRanges: refreshOptions.preserveOutsideRanges,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (runtime.codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
||||
if (runtime.codeMirrorCompletionStatus?.(currentView.state) && (prepared?.inputContext ?? isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType }))) {
|
||||
scheduleSemanticDiagnostics(900, {
|
||||
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||
preserveOutsideRanges: refreshOptions.preserveOutsideRanges,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
|
||||
if (props.databaseType !== "sqlserver") {
|
||||
if (!prepared && props.databaseType !== "sqlserver") {
|
||||
runtime.executableStatementRangeCache = executableStatementRangeCacheForDoc(runtime.executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
}
|
||||
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions());
|
||||
const diagnosticRanges = prepared?.diagnosticRanges ?? sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions());
|
||||
// SQL Server routine batches are excluded from `diagnosticRanges` (see
|
||||
// `sqlServerRoutineDefinitionRangesForViewport`), so they are recomputed here
|
||||
// and stay part of the replaced range set below.
|
||||
const sqlServerRoutineRanges = props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : [];
|
||||
const sqlServerRoutineRanges = prepared?.sqlServerRoutineRanges ?? (props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : []);
|
||||
if (diagnosticRanges.length === 0 && sqlServerRoutineRanges.length === 0) {
|
||||
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
|
||||
if (!refreshOptions.preserveOutsideRanges) setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDiagnostics: SqlSemanticDiagnostic[] = [];
|
||||
const oracleSyntaxDiagnostics = buildOracleSyntaxDiagnostics(sql, props.databaseType);
|
||||
const oracleSyntaxDiagnostics = prepared?.oracleSyntaxDiagnostics ?? buildOracleSyntaxDiagnostics(sql, props.databaseType);
|
||||
nextDiagnostics.push(
|
||||
...oracleSyntaxDiagnostics.filter((diagnostic) => {
|
||||
const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span);
|
||||
@@ -406,10 +405,10 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
// The analyzer never sees routine batches (the MsSql grammar cannot parse their
|
||||
// parameter list), so run the token-based routine syntax rules instead of leaving
|
||||
// a stored procedure without any check at all (dbx#9315).
|
||||
for (const range of sqlServerRoutineRanges) {
|
||||
nextDiagnostics.push(...offsetSqlSemanticDiagnostics(buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));
|
||||
for (const [index, range] of sqlServerRoutineRanges.entries()) {
|
||||
nextDiagnostics.push(...offsetSqlSemanticDiagnostics(prepared?.sqlServerRoutineDiagnostics[index] ?? buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));
|
||||
}
|
||||
const mysqlRoutineAnalysis = props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null;
|
||||
const mysqlRoutineAnalysis = prepared ? prepared.mysqlRoutineAnalysis : props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null;
|
||||
if (mysqlRoutineAnalysis) {
|
||||
nextDiagnostics.push(
|
||||
...mysqlRoutineAnalysis.diagnostics.filter((diagnostic) => {
|
||||
@@ -432,13 +431,20 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
if (runId !== semanticDiagnosticRunId) return;
|
||||
|
||||
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
|
||||
? buildSqlSemanticModel(range.sql, semanticCursor, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
})
|
||||
: null;
|
||||
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
|
||||
const semanticModel =
|
||||
SEMANTIC_SQL_COMPLETION_ENABLED && !backgroundAnalysis
|
||||
? buildSqlSemanticModel(range.sql, semanticCursor, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
})
|
||||
: null;
|
||||
const semanticAnalysis =
|
||||
SEMANTIC_SQL_COMPLETION_ENABLED && backgroundAnalysis
|
||||
? (await diagnosticAnalysisWorker.analyze({ sql: range.sql, cursor: semanticCursor, databaseType: props.databaseType, dialect: sqlBehaviorDialect(), visibleRanges: [], referenceAnalysis: analysis }))?.referenceAnalysis
|
||||
: semanticModel
|
||||
? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel)
|
||||
: analysis;
|
||||
if (!semanticAnalysis || runId !== semanticDiagnosticRunId) return;
|
||||
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
|
||||
const scopedAnalysis = {
|
||||
...semanticAnalysis,
|
||||
@@ -473,7 +479,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
if (diagnostic) nextDiagnostics.push(...offsetSqlSemanticDiagnostics([diagnostic], range, sql));
|
||||
}
|
||||
}
|
||||
if (options.preserveOutsideRanges) {
|
||||
if (refreshOptions.preserveOutsideRanges) {
|
||||
replaceSemanticDiagnosticsInRanges(nextDiagnostics, [...diagnosticRanges, ...sqlServerRoutineRanges], sql);
|
||||
} else {
|
||||
setSemanticDiagnostics(nextDiagnostics.sort(compareSqlSemanticDiagnostics));
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { ShallowRef } from "vue";
|
||||
import { RangeSet, RangeValue, type EditorState } from "@codemirror/state";
|
||||
import type { EditorView as EditorViewType, ViewPlugin as ViewPluginType } from "@codemirror/view";
|
||||
import { executableStatementRangeCacheForDoc, statementGutterStartIndexForCache, mapStatementGutterStartIndex, type ExecutableStatementRangeCache, type StatementGutterStartIndex } from "@/lib/sql/executableStatementRangeCache";
|
||||
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||||
import type { QueryEditorProps } from "./queryEditorTypes";
|
||||
import type { QueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime";
|
||||
import { createSqlStatementAnalysisWorker } from "@/lib/sql/sqlStatementAnalysisWorker";
|
||||
import { shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import type { FoldRange } from "@/lib/editor/codemirrorSqlBlockFolding";
|
||||
|
||||
class FoldRangeMarker extends RangeValue {
|
||||
startSide = 1;
|
||||
endSide = 1;
|
||||
}
|
||||
|
||||
const foldRangeMarker = new FoldRangeMarker();
|
||||
|
||||
interface QueryEditorStatementBoundariesOptions {
|
||||
props: Readonly<QueryEditorProps>;
|
||||
@@ -11,6 +22,7 @@ interface QueryEditorStatementBoundariesOptions {
|
||||
sqlStatementParameterOptions: () => SqlParameterOptions;
|
||||
cache: { value: ExecutableStatementRangeCache | null };
|
||||
runtime: QueryEditorCodeMirrorRuntime;
|
||||
driverProfile?: () => string | undefined;
|
||||
}
|
||||
|
||||
export function useQueryEditorStatementBoundaries(options: QueryEditorStatementBoundariesOptions) {
|
||||
@@ -18,14 +30,18 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
|
||||
// Lenient statement-boundary view shared by the run-statement gutter and the
|
||||
// current-statement frame. Re-parsing the whole document on every keystroke is
|
||||
// the dominant typing cost on large scripts, so while typing we only shift the
|
||||
// known start positions / frame range through the ChangeSet (O(statements)) and
|
||||
// rebuild exactly once after typing pauses. Paths that must stay exact (gutter
|
||||
// known positions through RangeSet mapping and rebuild after typing pauses,
|
||||
// off-thread for large documents. Paths that must stay exact (gutter
|
||||
// click-to-execute, execution picker) keep using the full on-demand parse.
|
||||
const STATEMENT_BOUNDARIES_REFRESH_MS = 150;
|
||||
const analysisWorker = createSqlStatementAnalysisWorker();
|
||||
let trackedView: EditorViewType | null = null;
|
||||
let pendingAnalysis: { doc: EditorState["doc"]; generation: number; result: Promise<ExecutableStatementRangeCache | null> } | null = null;
|
||||
|
||||
let statementBoundariesView: {
|
||||
doc: import("@codemirror/state").Text;
|
||||
startsIndex: StatementGutterStartIndex;
|
||||
folds: RangeSet<FoldRangeMarker>;
|
||||
frameRange: { from: number; to: number } | null;
|
||||
fresh: boolean;
|
||||
generation: number;
|
||||
@@ -35,20 +51,65 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
|
||||
|
||||
let statementBoundariesRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function refreshStatementBoundaries(state: import("@codemirror/state").EditorState) {
|
||||
cache.value = executableStatementRangeCacheForDoc(cache.value, state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
function cancelStatementBoundariesRefresh() {
|
||||
if (statementBoundariesRefreshTimer !== null) clearTimeout(statementBoundariesRefreshTimer);
|
||||
statementBoundariesRefreshTimer = null;
|
||||
}
|
||||
|
||||
function clearStatementBoundaries() {
|
||||
cancelStatementBoundariesRefresh();
|
||||
analysisWorker.dispose();
|
||||
pendingAnalysis = null;
|
||||
statementBoundariesGeneration += 1;
|
||||
cache.value = null;
|
||||
statementBoundariesView = null;
|
||||
}
|
||||
|
||||
function installStatementBoundaries(state: EditorState, result: ExecutableStatementRangeCache, folds?: Map<number, FoldRange>) {
|
||||
cache.value = result;
|
||||
statementBoundariesView = {
|
||||
doc: state.doc,
|
||||
startsIndex: statementGutterStartIndexForCache(cache.value),
|
||||
startsIndex: statementGutterStartIndexForCache(result),
|
||||
folds: folds
|
||||
? RangeSet.of(
|
||||
[...folds.values()].map((range) => foldRangeMarker.range(range.from, range.to)),
|
||||
true,
|
||||
)
|
||||
: RangeSet.empty,
|
||||
frameRange: null,
|
||||
fresh: true,
|
||||
generation: statementBoundariesGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureStatementCache(state: EditorState): Promise<ExecutableStatementRangeCache | null> {
|
||||
if (pendingAnalysis?.doc === state.doc && pendingAnalysis.generation === statementBoundariesGeneration) return pendingAnalysis.result;
|
||||
const generation = statementBoundariesGeneration;
|
||||
const result = analysisWorker.analyze({ sql: state.doc.toString(), databaseType: props.databaseType, parameterOptions: sqlStatementParameterOptions(), includeFolds: true, syntaxDialect: props.syntaxDialect ?? props.dialect, driverProfile: options.driverProfile?.() }).then((analysis) => {
|
||||
const currentView = view.value ?? trackedView;
|
||||
if (!analysis || generation !== statementBoundariesGeneration || currentView?.state.doc !== state.doc) return null;
|
||||
const resolved = { ...analysis, doc: state.doc };
|
||||
installStatementBoundaries(state, resolved, analysis.folds);
|
||||
const refreshEffect = codeMirrorRuntime.statementBoundariesRefreshEffect;
|
||||
if (refreshEffect) currentView.dispatch({ effects: refreshEffect.of(null) });
|
||||
return resolved;
|
||||
});
|
||||
pendingAnalysis = { doc: state.doc, generation, result };
|
||||
return result;
|
||||
}
|
||||
|
||||
function refreshStatementBoundaries(state: EditorState) {
|
||||
if (shouldUseQueryEditorLargeDocumentModeForSize(state.doc.length, state.doc.lines)) {
|
||||
void ensureStatementCache(state);
|
||||
} else {
|
||||
installStatementBoundaries(state, executableStatementRangeCacheForDoc(cache.value, state.doc, props.databaseType, sqlStatementParameterOptions()));
|
||||
}
|
||||
}
|
||||
|
||||
interface StatementBoundariesView {
|
||||
doc: import("@codemirror/state").Text;
|
||||
startsIndex: StatementGutterStartIndex;
|
||||
folds: RangeSet<FoldRangeMarker>;
|
||||
frameRange: { from: number; to: number } | null;
|
||||
fresh: boolean;
|
||||
generation: number;
|
||||
@@ -60,6 +121,7 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
|
||||
// reference current through ChangeSet mapping, so this stays cheap.
|
||||
function statementBoundariesForState(state: import("@codemirror/state").EditorState): StatementBoundariesView {
|
||||
if (statementBoundariesView && statementBoundariesView.doc === state.doc && statementBoundariesView.generation === statementBoundariesGeneration) return statementBoundariesView;
|
||||
statementBoundariesView = { doc: state.doc, startsIndex: { starts: RangeSet.empty, executableLineStarts: RangeSet.empty }, folds: RangeSet.empty, frameRange: null, fresh: false, generation: statementBoundariesGeneration };
|
||||
refreshStatementBoundaries(state);
|
||||
return statementBoundariesView!;
|
||||
}
|
||||
@@ -82,8 +144,14 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
|
||||
function createTrackingPlugin(ViewPlugin: typeof ViewPluginType) {
|
||||
const statementBoundariesTrackingPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
constructor(currentView: EditorViewType) {
|
||||
trackedView = currentView;
|
||||
}
|
||||
|
||||
update(update: import("@codemirror/view").ViewUpdate) {
|
||||
if (!update.docChanged) return;
|
||||
analysisWorker.cancel();
|
||||
pendingAnalysis = null;
|
||||
const boundaries = statementBoundariesView;
|
||||
// No consumer (run gutter off + statement frame off) ever initialized
|
||||
// the view — nothing to maintain and no refresh to schedule.
|
||||
@@ -92,6 +160,7 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
|
||||
statementBoundariesView = {
|
||||
doc: update.state.doc,
|
||||
startsIndex: mapStatementGutterStartIndex(boundaries.startsIndex, update.changes),
|
||||
folds: boundaries.folds.map(update.changes),
|
||||
frameRange: boundaries.frameRange
|
||||
? {
|
||||
from: update.changes.mapPos(boundaries.frameRange.from, 1),
|
||||
@@ -106,19 +175,32 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (statementBoundariesRefreshTimer !== null) {
|
||||
clearTimeout(statementBoundariesRefreshTimer);
|
||||
statementBoundariesRefreshTimer = null;
|
||||
}
|
||||
clearStatementBoundaries();
|
||||
trackedView = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
return statementBoundariesTrackingPlugin;
|
||||
}
|
||||
return {
|
||||
foldRangeForState(state: EditorState, lineStart: number): FoldRange | null | undefined {
|
||||
if (!shouldUseQueryEditorLargeDocumentModeForSize(state.doc.length, state.doc.lines)) return undefined;
|
||||
const lineEnd = state.doc.lineAt(lineStart).to;
|
||||
let range: FoldRange | null = null;
|
||||
statementBoundariesForState(state).folds.between(lineEnd, lineEnd, (from, to) => {
|
||||
if (from !== lineEnd) return;
|
||||
range = { from, to };
|
||||
return false;
|
||||
});
|
||||
return range;
|
||||
},
|
||||
statementBoundariesForState,
|
||||
createTrackingPlugin,
|
||||
ensureStatementCache,
|
||||
clear: clearStatementBoundaries,
|
||||
invalidate() {
|
||||
analysisWorker.cancel();
|
||||
pendingAnalysis = null;
|
||||
statementBoundariesGeneration += 1;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Download, Square, CheckSquare, Search, X, Loader2, Wrench } from "@lucide/vue";
|
||||
import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker";
|
||||
import { isQueryTimeoutErrorMessage } from "@/lib/sql/queryError";
|
||||
import type { SqlInsertDialect } from "@/lib/export/sqlInsertMode";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -64,6 +65,7 @@ const POSTGRES_ALL_SCHEMAS = "__DBX_ALL_SCHEMAS__";
|
||||
// Options
|
||||
const includeStructure = ref(true);
|
||||
const includeData = ref(true);
|
||||
const insertDialect = ref<SqlInsertDialect>("source");
|
||||
const includeObjects = ref(true);
|
||||
const includeCreateDatabase = ref(false);
|
||||
const dropTableIfExists = ref(false);
|
||||
@@ -396,6 +398,7 @@ async function startExport() {
|
||||
selectedTables: !isPostgresAllSchemas.value && (includeStructure.value || includeData.value) ? buildSelectedTablesPayload(tables.value, selectedTables.value) : undefined,
|
||||
includeStructure: includeStructure.value,
|
||||
includeData: includeData.value,
|
||||
insertDialect: insertDialect.value,
|
||||
includeObjects: includeObjects.value,
|
||||
includeCreateDatabase: includeCreateDatabase.value,
|
||||
dropTableIfExists: dropTableIfExists.value,
|
||||
@@ -538,6 +541,7 @@ async function startAllDatabasesExport() {
|
||||
filePath,
|
||||
includeStructure: includeStructure.value,
|
||||
includeData: includeData.value,
|
||||
insertDialect: insertDialect.value,
|
||||
includeObjects: includeObjects.value,
|
||||
includeCreateDatabase: includeCreateDatabase.value,
|
||||
dropTableIfExists: dropTableIfExists.value,
|
||||
@@ -659,6 +663,7 @@ function resetState() {
|
||||
databaseFilter.value = "";
|
||||
includeStructure.value = true;
|
||||
includeData.value = true;
|
||||
insertDialect.value = "source";
|
||||
includeObjects.value = true;
|
||||
includeCreateDatabase.value = false;
|
||||
dropTableIfExists.value = false;
|
||||
@@ -947,6 +952,16 @@ watch(
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("databaseExport.includeData") }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<button type="button" class="flex items-center gap-2 text-left text-xs" :class="includeData ? 'cursor-pointer' : 'cursor-not-allowed text-muted-foreground/50'" :disabled="!includeData" @click="insertDialect = insertDialect === 'standard' ? 'source' : 'standard'">
|
||||
<CheckSquare v-if="includeData && insertDialect === 'standard'" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("databaseExport.standardSqlInsert") }}
|
||||
</button>
|
||||
<p class="pl-5 text-[11px] text-muted-foreground">
|
||||
{{ t("databaseExport.standardSqlInsertDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeObjects = !includeObjects">
|
||||
<CheckSquare v-if="includeObjects" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, GitCompareArrows, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
|
||||
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, Database, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, GitCompareArrows, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
|
||||
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { dataTransferFailureCopyText, sqlFileFailureCopyText } from "@/components/export/failureDetailCopyText";
|
||||
import SqlFileProgressIndicator from "@/components/sql-file/SqlFileProgressIndicator.vue";
|
||||
@@ -108,6 +108,7 @@ const taskTitle = (task: ExportTask) => {
|
||||
}
|
||||
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
|
||||
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
|
||||
if (task.kind === "data-generation") return t("exportProgress.dataGenerationTitle", { name: task.tableName });
|
||||
if (task.kind === "multi-db-execution") return t("exportProgress.multiDbExecutionTitle", { name: task.tableName });
|
||||
if (task.kind === "schema-diff") return t("exportProgress.schemaDiffTitle", { name: task.tableName });
|
||||
if (task.kind === "data-compare") return t("exportProgress.dataCompareTitle", { name: task.tableName });
|
||||
@@ -187,6 +188,16 @@ const rowsText = (task: ExportTask) => {
|
||||
const durationText = t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - (task.startedAt ?? finishedAt)) });
|
||||
return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText} · ${durationText}` : `${tableText} · ${durationText}`;
|
||||
}
|
||||
if (task.kind === "data-generation") {
|
||||
const tableText = task.totalTables
|
||||
? t("exportProgress.tablesCount", {
|
||||
current: (task.tableIndex ?? 0).toLocaleString(),
|
||||
total: task.totalTables.toLocaleString(),
|
||||
})
|
||||
: "";
|
||||
const rowText = task.totalRows ? `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()} ${t("exportProgress.rowsShort")}` : `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
|
||||
return [tableText, task.currentTable, rowText].filter(Boolean).join(" · ");
|
||||
}
|
||||
if (task.kind === "multi-db-execution") {
|
||||
return t("exportProgress.multiDbTargets", {
|
||||
completed: (task.multiDbCompleted ?? 0).toLocaleString(),
|
||||
@@ -232,6 +243,7 @@ const statusIcon = (task: ExportTask) => {
|
||||
if (task.kind === "data-dictionary") return FileDown;
|
||||
if (task.kind === "sql-file") return FileCode2;
|
||||
if (task.kind === "data-transfer") return ArrowRightLeft;
|
||||
if (task.kind === "data-generation") return Database;
|
||||
if (task.kind === "multi-db-execution") return Layers3;
|
||||
if (task.kind === "schema-diff" || task.kind === "data-compare") return GitCompareArrows;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SqlExportOptions, SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
import type { SqlExportColumnSelection, SqlExportOptions, SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const props = defineProps<{ allowSplit?: boolean }>();
|
||||
const props = defineProps<{ allowSplit?: boolean; columns?: SqlExportColumnSelection[] }>();
|
||||
const selected = ref<SqlInsertMode>("batch");
|
||||
const selectedColumnIndexes = ref(props.columns?.map((column) => column.sourceIndex) ?? []);
|
||||
const splitSqlOutput = ref(false);
|
||||
const splitSqlPartMaxMb = ref(100);
|
||||
const MIN_SPLIT_SQL_PART_MB = 1;
|
||||
const MAX_SPLIT_SQL_PART_MB = 4096;
|
||||
let outcomeEmitted = false;
|
||||
const columnSelectionEnabled = computed(() => props.columns !== undefined);
|
||||
const selectedColumnSet = computed(() => new Set(selectedColumnIndexes.value));
|
||||
const selectedColumns = computed(() => props.columns?.filter((column) => selectedColumnSet.value.has(column.sourceIndex)) ?? []);
|
||||
const canConfirm = computed(() => !columnSelectionEnabled.value || selectedColumns.value.length > 0);
|
||||
const duplicateColumnNames = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const column of props.columns ?? []) counts.set(column.name, (counts.get(column.name) ?? 0) + 1);
|
||||
return new Set([...counts].flatMap(([name, count]) => (count > 1 ? [name] : [])));
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [options: SqlExportOptions];
|
||||
@@ -27,14 +37,24 @@ function normalizedSplitSqlPartMaxMb(): number {
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!canConfirm.value) return;
|
||||
outcomeEmitted = true;
|
||||
open.value = false;
|
||||
emit("confirm", {
|
||||
insertMode: selected.value,
|
||||
splitMaxMb: props.allowSplit && splitSqlOutput.value ? normalizedSplitSqlPartMaxMb() : undefined,
|
||||
...(columnSelectionEnabled.value ? { selectedColumns: selectedColumns.value } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllColumns() {
|
||||
selectedColumnIndexes.value = props.columns?.map((column) => column.sourceIndex) ?? [];
|
||||
}
|
||||
|
||||
function clearColumns() {
|
||||
selectedColumnIndexes.value = [];
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (outcomeEmitted) return;
|
||||
outcomeEmitted = true;
|
||||
@@ -69,6 +89,27 @@ function onOpenChange(value: boolean) {
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ t("grid.sqlInsertModeSingleDescription") }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="columnSelectionEnabled" class="space-y-2 rounded-md border p-3" data-sql-export-columns>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ t("databaseExport.sqlColumnSelection") }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t("databaseExport.selectedColumns", { selected: selectedColumns.length, total: props.columns?.length ?? 0 }) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" data-sql-export-select-all @click="selectAllColumns">{{ t("databaseExport.selectAllColumns") }}</Button>
|
||||
<Button type="button" variant="ghost" size="sm" data-sql-export-clear @click="clearColumns">{{ t("databaseExport.clearColumns") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
|
||||
<label v-for="column in props.columns" :key="column.sourceIndex" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm hover:bg-accent/50">
|
||||
<input v-model="selectedColumnIndexes" type="checkbox" :value="column.sourceIndex" class="h-4 w-4" :data-sql-export-column="column.sourceIndex" />
|
||||
<span class="truncate"
|
||||
>{{ column.name }}<span v-if="duplicateColumnNames.has(column.name)" class="text-muted-foreground"> #{{ column.nameOccurrence + 1 }}</span></span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="!canConfirm" role="alert" class="text-xs text-destructive" data-sql-export-column-error>{{ t("databaseExport.selectAtLeastOneColumn") }}</p>
|
||||
</div>
|
||||
<div v-if="props.allowSplit" class="space-y-2 rounded-md border p-3">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input v-model="splitSqlOutput" type="checkbox" class="h-4 w-4" data-sql-split-output />
|
||||
@@ -85,7 +126,7 @@ function onOpenChange(value: boolean) {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="onCancel">{{ t("common.cancel") }}</Button>
|
||||
<Button data-sql-insert-mode-confirm @click="onConfirm">{{ t("common.confirm") }}</Button>
|
||||
<Button :disabled="!canConfirm" data-sql-insert-mode-confirm @click="onConfirm">{{ t("common.confirm") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -112,6 +112,31 @@ describe("data dictionary background task", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("data generation background task", () => {
|
||||
it("shows global progress and opens the resumable task", async () => {
|
||||
const tracker = useExportTracker();
|
||||
const onOpen = vi.fn();
|
||||
const task = tracker.addDataGenerationTask("generate-task", "app.users", 1_000, 2, onOpen);
|
||||
tracker.updateDataGenerationTask(task.exportId, {
|
||||
status: "Running",
|
||||
tableIndex: 1,
|
||||
totalTables: 2,
|
||||
currentTable: "users",
|
||||
rowsGenerated: 250,
|
||||
totalRows: 1_000,
|
||||
});
|
||||
|
||||
await mountPopover();
|
||||
|
||||
expect(document.body.textContent).toContain("Data generation: app.users");
|
||||
expect(document.body.textContent).toContain("1 / 2 tables · users · 250 / 1,000 rows");
|
||||
const openButton = document.body.querySelector<HTMLButtonElement>('button[title="Open task"]');
|
||||
expect(openButton).not.toBeNull();
|
||||
openButton?.click();
|
||||
expect(onOpen).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL file byte progress", () => {
|
||||
it("keeps legacy progress indeterminate instead of using successful statements as the total", async () => {
|
||||
const tracker = useExportTracker();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { createApp, h, nextTick, type App, defineComponent } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import { sqlExportColumnChoices } from "@/lib/export/sqlExportColumns";
|
||||
import type { SqlExportColumnSelection } from "@/lib/export/sqlInsertMode";
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
@@ -37,13 +39,13 @@ afterEach(() => {
|
||||
i18n.global.locale.value = "en";
|
||||
});
|
||||
|
||||
async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit = false) {
|
||||
async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit = false, columns?: SqlExportColumnSelection[]) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(SqlInsertModeDialog, { open: true, allowSplit, onConfirm, onCancel });
|
||||
return () => h(SqlInsertModeDialog, { open: true, allowSplit, columns, onConfirm, onCancel });
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -54,6 +56,37 @@ async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit
|
||||
}
|
||||
|
||||
describe("SqlInsertModeDialog", () => {
|
||||
it("defaults to every column and allows deselecting one duplicate independently", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const choices = sqlExportColumnChoices(["id", "name", "id"]);
|
||||
await mountDialog(onConfirm, () => {}, false, choices);
|
||||
expect([...document.querySelectorAll<HTMLInputElement>("[data-sql-export-column]")].every((input) => input.checked)).toBe(true);
|
||||
expect(document.body.textContent).toContain("#1");
|
||||
expect(document.body.textContent).toContain("#2");
|
||||
document.querySelector<HTMLInputElement>('[data-sql-export-column="0"]')?.click();
|
||||
await nextTick();
|
||||
document.querySelector<HTMLButtonElement>("[data-sql-insert-mode-confirm]")?.click();
|
||||
expect(onConfirm).toHaveBeenCalledWith({ insertMode: "batch", splitMaxMb: undefined, selectedColumns: choices.slice(1) });
|
||||
});
|
||||
|
||||
it("blocks an empty selection and restores every column with select all", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const choices = sqlExportColumnChoices(["id", "name"]);
|
||||
await mountDialog(onConfirm, () => {}, true, choices);
|
||||
document.querySelector<HTMLButtonElement>("[data-sql-export-clear]")?.click();
|
||||
await nextTick();
|
||||
const confirm = document.querySelector<HTMLButtonElement>("[data-sql-insert-mode-confirm]");
|
||||
expect(confirm?.disabled).toBe(true);
|
||||
expect(document.querySelector("[data-sql-export-column-error]")).not.toBeNull();
|
||||
confirm?.click();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
document.querySelector<HTMLButtonElement>("[data-sql-export-select-all]")?.click();
|
||||
await nextTick();
|
||||
expect(confirm?.disabled).toBe(false);
|
||||
confirm?.click();
|
||||
expect(onConfirm).toHaveBeenCalledWith({ insertMode: "batch", splitMaxMb: undefined, selectedColumns: choices });
|
||||
});
|
||||
|
||||
it("defaults to batch mode and emits the selected single-row mode", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
await mountDialog(onConfirm);
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onMounted, watch, type ComponentPublicInstance } from "vue";
|
||||
import { reactive, ref, computed, watch, type ComponentPublicInstance } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ColumnGenerateConfig, GenerateResult, TableGenerateConfig } from "@/lib/dataGrid/dataGenerate";
|
||||
import {
|
||||
createTableGenerateState,
|
||||
defaultGeneratorParams,
|
||||
displayGeneratedValue,
|
||||
findGeneratorKey,
|
||||
formatGeneratedRowValues,
|
||||
formatGeneratedValue,
|
||||
generateInsertBatches,
|
||||
generateTableData,
|
||||
generateTableRowsChunk,
|
||||
splitValueRowsByByteBudget,
|
||||
supportsGeneratedMultiRowValues,
|
||||
UniqueValueGenerationError,
|
||||
} from "@/lib/dataGrid/dataGenerate";
|
||||
import { errorMessage, isQueryCanceledError, summarizeBatchResults } from "@/lib/dataGrid/generateInsertAccounting";
|
||||
import type { ColumnGenerateConfig, TableGenerateConfig } from "@/lib/dataGrid/dataGenerate";
|
||||
import { defaultGeneratorParams, displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generateTableData, supportsGeneratedMultiRowValues, UniqueValueGenerationError } from "@/lib/dataGrid/dataGenerate";
|
||||
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { uniqueConstraintColumns } from "@/lib/table/uniqueConstraintColumns";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import GeneratorParamsPanel from "./params/GeneratorParamsPanel.vue";
|
||||
import type { ColumnInfo, QueryResult, TableInfo } from "@/types/database";
|
||||
import type { ColumnInfo, TableInfo } from "@/types/database";
|
||||
import { cancelDataGenerateSession, getDataGenerateSession, startDataGenerateSession, type DataGenerateTarget } from "@/composables/useDataGenerateSession";
|
||||
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogScrollContent, DialogContent, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Database, Table, Columns, Loader2, Save, Upload, Settings, ChevronRight, X, AlertCircle, ArrowUp, ArrowDown } from "@lucide/vue";
|
||||
import { Database, Table, Columns, Loader2, Save, Upload, Settings, ChevronRight, X, AlertCircle, ArrowUp, ArrowDown, Minimize2 } from "@lucide/vue";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
@@ -45,6 +31,7 @@ const props = defineProps<{
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillTable?: string;
|
||||
sessionId?: string | null;
|
||||
}>();
|
||||
|
||||
// Left tree state
|
||||
@@ -72,15 +59,7 @@ const panelColumnName = ref<string | null>(null);
|
||||
|
||||
// Step state: config -> preview
|
||||
const currentStep = ref<"config" | "preview" | "result">("config");
|
||||
interface GeneratedTableResult extends GenerateResult {
|
||||
tableName: string;
|
||||
schema: string;
|
||||
/** Rows that will actually be inserted. `rows` only holds a preview sample. */
|
||||
targetRowCount: number;
|
||||
isSample: boolean;
|
||||
/** Column config after auto-increment start values have been resolved. */
|
||||
resolvedColumns: ColumnGenerateConfig[];
|
||||
}
|
||||
type GeneratedTableResult = DataGenerateTarget;
|
||||
const generatedResults = ref<GeneratedTableResult[]>([]);
|
||||
const generationError = ref("");
|
||||
|
||||
@@ -92,12 +71,6 @@ const generationError = ref("");
|
||||
const PREVIEW_SAMPLE_ROWS = 50;
|
||||
const MAX_ROW_COUNT = 100_000_000;
|
||||
const DEFAULT_BATCH_ROWS = 1000;
|
||||
/**
|
||||
* Conservative per-statement budget. The backend clamps MySQL batches at 4MB
|
||||
* and derives a `max_allowed_packet` margin, so staying near 1MB keeps the
|
||||
* common 4MB / 16MB / 64MB server settings safe without extra round trips.
|
||||
*/
|
||||
const MAX_BATCH_BYTES = 1024 * 1024;
|
||||
const LARGE_ROW_COUNT_HINT = 100_000;
|
||||
|
||||
function normalizeRowCount(value: number): number {
|
||||
@@ -432,6 +405,7 @@ const currentPreview = computed<GeneratedTableResult>(
|
||||
generatedResults.value[previewTableIndex.value] ?? {
|
||||
tableName: "",
|
||||
schema: "",
|
||||
database: "",
|
||||
columns: [],
|
||||
rows: [],
|
||||
sql: "",
|
||||
@@ -475,6 +449,8 @@ function buildSampleResult(cfg: TableGenerateConfig, columns: ColumnGenerateConf
|
||||
return {
|
||||
tableName: cfg.tableName,
|
||||
schema: cfg.schema,
|
||||
database: cfg.database,
|
||||
tableType: cfg.tableType,
|
||||
targetRowCount,
|
||||
isSample: targetRowCount > sampleCount,
|
||||
resolvedColumns: columns,
|
||||
@@ -557,17 +533,10 @@ function copySampleSql() {
|
||||
void navigator.clipboard.writeText(allSql);
|
||||
}
|
||||
|
||||
const executing = ref(false);
|
||||
|
||||
interface TableResult {
|
||||
table: string;
|
||||
total: number;
|
||||
ok: number;
|
||||
err: number;
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
const executeResults = ref<TableResult[]>([]);
|
||||
const activeSessionId = ref<string | null>(props.sessionId ?? null);
|
||||
const activeSession = computed(() => getDataGenerateSession(activeSessionId.value));
|
||||
const executing = computed(() => activeSession.value?.status === "running" || activeSession.value?.status === "cancelling");
|
||||
const executeResults = computed(() => activeSession.value?.results ?? []);
|
||||
|
||||
const generateOptions = reactive({
|
||||
continueOnError: false,
|
||||
@@ -589,17 +558,7 @@ watch(
|
||||
|
||||
const optionsDialogOpen = ref(false);
|
||||
|
||||
interface InsertProgress {
|
||||
tableName: string;
|
||||
tableIndex: number;
|
||||
tableCount: number;
|
||||
insertedRows: number;
|
||||
totalRows: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
const insertProgress = ref<InsertProgress | null>(null);
|
||||
const insertCancelled = ref(false);
|
||||
let activeExecutionId: string | null = null;
|
||||
const insertProgress = computed(() => activeSession.value?.progress ?? null);
|
||||
|
||||
const insertPercent = computed(() => {
|
||||
const p = insertProgress.value;
|
||||
@@ -608,9 +567,8 @@ const insertPercent = computed(() => {
|
||||
});
|
||||
|
||||
function cancelInsert() {
|
||||
if (!executing.value) return;
|
||||
insertCancelled.value = true;
|
||||
if (activeExecutionId) void api.cancelQuery(activeExecutionId);
|
||||
if (!executing.value || !activeSessionId.value) return;
|
||||
void cancelDataGenerateSession(activeSessionId.value);
|
||||
}
|
||||
|
||||
function normalizeTimeoutSecs(value: number): number {
|
||||
@@ -643,119 +601,6 @@ function allSqlStatements(): string[] {
|
||||
return generatedResults.value.flatMap((r) => sqlStatementsForTable(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams one table: generate a chunk, insert it, drop the references, repeat.
|
||||
*
|
||||
* Peak memory stays proportional to `batchRows` instead of the requested row
|
||||
* count, and each awaited round trip gives the main thread a chance to paint
|
||||
* the progress bar.
|
||||
*/
|
||||
async function streamInsertTable(cid: string, db: string, r: GeneratedTableResult, executionId: string, onRows: (insertedRows: number) => void): Promise<{ ok: number; attempted: number; error: string; cancelled: boolean }> {
|
||||
const cfg = configs[tableKey(r.schema, r.tableName)];
|
||||
const genCfg: TableGenerateConfig = {
|
||||
tableName: r.tableName,
|
||||
schema: r.schema,
|
||||
database: props.prefillDatabase ?? cfg?.database ?? "",
|
||||
tableType: cfg?.tableType,
|
||||
rowCount: r.targetRowCount,
|
||||
columns: r.resolvedColumns,
|
||||
};
|
||||
const state = createTableGenerateState(genCfg, dbType.value);
|
||||
const schema = r.schema || props.prefillSchema;
|
||||
const forceSingleRow = !generateOptions.extendedInsert;
|
||||
const timeoutSecs = normalizeTimeoutSecs(generateOptions.timeoutSecs);
|
||||
const batchRows = Math.max(1, Math.floor(generateOptions.batchRows) || DEFAULT_BATCH_ROWS);
|
||||
let ok = 0;
|
||||
let attempted = 0;
|
||||
let lastError = "";
|
||||
let cancelled = false;
|
||||
|
||||
const run = (sql: string): Promise<QueryResult[]> =>
|
||||
api.executeMultiWithProgress(cid, db, sql, () => {}, schema, {
|
||||
timeoutSecs,
|
||||
useTransaction: generateOptions.useTransaction,
|
||||
continueOnError: generateOptions.continueOnError,
|
||||
executionId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Executes one statement batch and folds the outcome into the running totals.
|
||||
* Returns false when the caller has to stop the table.
|
||||
*
|
||||
* The returned per-statement results are inspected instead of trusting the
|
||||
* `await`: several backend paths report a failed statement inside the result
|
||||
* array while still resolving, so counting rows on the await alone would
|
||||
* report failed batches as inserted.
|
||||
*/
|
||||
const executeBatch = async (statements: string[], rowsPerStatement: number[]): Promise<boolean> => {
|
||||
const expectedRows = rowsPerStatement.reduce((sum, rows) => sum + rows, 0);
|
||||
attempted += expectedRows;
|
||||
try {
|
||||
const results = await run(statements.join("\n"));
|
||||
// A row-carrying batch that comes back without per-statement results is
|
||||
// unconfirmed; counting it would over-report inserts.
|
||||
if (expectedRows > 0 && (!results || results.length === 0)) {
|
||||
if (!lastError) lastError = "Backend returned no result for the batch";
|
||||
return generateOptions.continueOnError;
|
||||
}
|
||||
const outcome = summarizeBatchResults(results, rowsPerStatement);
|
||||
ok += outcome.insertedRows;
|
||||
if (!outcome.failed) return true;
|
||||
if (!lastError) lastError = outcome.error ?? "Statement failed";
|
||||
console.error("[startInsert] SQL error:", lastError);
|
||||
return generateOptions.continueOnError;
|
||||
} catch (e: unknown) {
|
||||
// Cancelling a transaction or a single-statement round trip surfaces as
|
||||
// an error, so it has to be told apart from a real failure.
|
||||
if (insertCancelled.value || isQueryCanceledError(e)) {
|
||||
cancelled = true;
|
||||
return false;
|
||||
}
|
||||
const msg = errorMessage(e);
|
||||
console.error("[startInsert] SQL error:", msg);
|
||||
if (!lastError) lastError = msg;
|
||||
return generateOptions.continueOnError;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = () => ({ ok, attempted, error: cancelled ? "" : lastError, cancelled });
|
||||
|
||||
if (generateOptions.truncate) {
|
||||
const targetTable = qualifiedTableName({ databaseType: dbType.value, schema: r.schema, tableName: r.tableName, database: props.prefillDatabase });
|
||||
// TRUNCATE carries no generated rows, so it only gates the rest of the run.
|
||||
const truncateOk = await executeBatch([`TRUNCATE TABLE ${targetTable};`], [0]);
|
||||
if (!truncateOk) return finish();
|
||||
}
|
||||
|
||||
while (state.nextIndex < r.targetRowCount && !insertCancelled.value) {
|
||||
let valueRows: string[];
|
||||
try {
|
||||
const chunkRows = generateTableRowsChunk(genCfg, state, batchRows);
|
||||
if (chunkRows.length === 0) break;
|
||||
valueRows = chunkRows.map((row) => formatGeneratedRowValues(genCfg, dbType.value, state, row));
|
||||
} catch (e: unknown) {
|
||||
// Generation failures (unique space exhausted at row 500k, say) must not
|
||||
// discard the rows that earlier chunks already inserted.
|
||||
if (!lastError) lastError = generationErrorMessage(e);
|
||||
return finish();
|
||||
}
|
||||
|
||||
for (const group of splitValueRowsByByteBudget(state, valueRows, MAX_BATCH_BYTES)) {
|
||||
const { statements, rowsPerStatement } = generateInsertBatches(dbType.value, state, group, forceSingleRow);
|
||||
if (statements.length === 0) continue;
|
||||
const keepGoing = await executeBatch(statements, rowsPerStatement);
|
||||
onRows(ok);
|
||||
if (!keepGoing) return finish();
|
||||
}
|
||||
onRows(ok);
|
||||
// Yield so the progress bar repaints between chunks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
if (insertCancelled.value) cancelled = true;
|
||||
return finish();
|
||||
}
|
||||
|
||||
async function startInsert() {
|
||||
if (executing.value || generationError.value) return;
|
||||
const cid = props.prefillConnectionId;
|
||||
@@ -771,67 +616,46 @@ async function startInsert() {
|
||||
sql: guardSql,
|
||||
source: t("production.sourceDataGenerate"),
|
||||
execute: async () => {
|
||||
executing.value = true;
|
||||
insertCancelled.value = false;
|
||||
const executionId = uuid();
|
||||
activeExecutionId = executionId;
|
||||
const startedAt = performance.now();
|
||||
const perTable: TableResult[] = [];
|
||||
let stopAll = false;
|
||||
try {
|
||||
for (let ti = 0; ti < targets.length && !stopAll; ti++) {
|
||||
const r = targets[ti];
|
||||
insertProgress.value = {
|
||||
tableName: r.tableName,
|
||||
tableIndex: ti + 1,
|
||||
tableCount: targets.length,
|
||||
insertedRows: 0,
|
||||
totalRows: r.targetRowCount,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
let outcome: { ok: number; attempted: number; error: string; cancelled: boolean };
|
||||
let insertedSoFar = 0;
|
||||
try {
|
||||
outcome = await streamInsertTable(cid, db, r, executionId, (insertedRows) => {
|
||||
insertedSoFar = insertedRows;
|
||||
if (insertProgress.value) {
|
||||
insertProgress.value = { ...insertProgress.value, insertedRows, elapsedMs: Math.round(performance.now() - startedAt) };
|
||||
}
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
// Safety net: even an unexpected throw must not hide the rows that
|
||||
// earlier batches already inserted.
|
||||
const wasCancelled = insertCancelled.value || isQueryCanceledError(e);
|
||||
outcome = { ok: insertedSoFar, attempted: insertedSoFar, error: wasCancelled ? "" : errorMessage(e), cancelled: wasCancelled };
|
||||
}
|
||||
const failedRows = Math.max(0, Math.max(outcome.attempted, outcome.ok) - outcome.ok);
|
||||
perTable.push({
|
||||
table: r.tableName,
|
||||
total: r.targetRowCount,
|
||||
ok: outcome.ok,
|
||||
err: failedRows,
|
||||
error: outcome.error || undefined,
|
||||
cancelled: outcome.cancelled || undefined,
|
||||
});
|
||||
if (outcome.ok > 0) {
|
||||
store.invalidateMetadataCache(cid, db, r.schema || props.prefillSchema || undefined, r.tableName);
|
||||
}
|
||||
if ((outcome.error && !generateOptions.continueOnError) || outcome.cancelled) stopAll = true;
|
||||
}
|
||||
} finally {
|
||||
activeExecutionId = null;
|
||||
}
|
||||
executeResults.value = perTable;
|
||||
currentStep.value = "result";
|
||||
const session = startDataGenerateSession(
|
||||
{
|
||||
connectionId: cid,
|
||||
database: db,
|
||||
prefillSchema: props.prefillSchema,
|
||||
databaseType: dbType.value,
|
||||
label: targets.length === 1 ? `${db}.${targets[0].tableName}` : `${db} (${targets.length})`,
|
||||
targets,
|
||||
options: {
|
||||
continueOnError: generateOptions.continueOnError,
|
||||
truncate: generateOptions.truncate,
|
||||
useTransaction: generateOptions.useTransaction,
|
||||
extendedInsert: generateOptions.extendedInsert,
|
||||
timeoutSecs: normalizeTimeoutSecs(generateOptions.timeoutSecs),
|
||||
batchRows: Math.max(1, Math.floor(generateOptions.batchRows) || DEFAULT_BATCH_ROWS),
|
||||
},
|
||||
},
|
||||
{
|
||||
invalidateMetadataCache: (connectionId, database, schema, table) => store.invalidateMetadataCache(connectionId, database, schema, table),
|
||||
formatGenerationError: generationErrorMessage,
|
||||
},
|
||||
);
|
||||
activeSessionId.value = session.id;
|
||||
applyDataGenerateSession(session);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
executing.value = false;
|
||||
insertProgress.value = null;
|
||||
} catch (error) {
|
||||
generationError.value = generationErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDataGenerateSession(session = activeSession.value) {
|
||||
if (!session) return;
|
||||
generatedResults.value = session.config.targets;
|
||||
generationError.value = session.error ?? "";
|
||||
previewTableIndex.value = Math.min(previewTableIndex.value, Math.max(0, generatedResults.value.length - 1));
|
||||
currentStep.value = session.status === "running" || session.status === "cancelling" ? "preview" : "result";
|
||||
}
|
||||
|
||||
const orderDialogOpen = ref(false);
|
||||
const tableOrder = ref<string[]>([]);
|
||||
|
||||
@@ -892,15 +716,29 @@ watch(
|
||||
{ deep: true, flush: "post" },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadSchemas();
|
||||
});
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) {
|
||||
watch(
|
||||
[() => open.value, () => props.sessionId],
|
||||
([isOpen, sessionId]) => {
|
||||
if (!isOpen) return;
|
||||
const session = getDataGenerateSession(sessionId);
|
||||
if (session) {
|
||||
activeSessionId.value = session.id;
|
||||
applyDataGenerateSession(session);
|
||||
return;
|
||||
}
|
||||
activeSessionId.value = null;
|
||||
void loadSchemas();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const session = getDataGenerateSession(activeSessionId.value);
|
||||
return session ? `${session.id}:${session.version}` : "";
|
||||
},
|
||||
() => applyDataGenerateSession(),
|
||||
);
|
||||
|
||||
interface GenerateProfileJson {
|
||||
version: 1;
|
||||
@@ -1296,8 +1134,9 @@ async function onFileSelected(event: Event) {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="open = false">
|
||||
<X class="mr-1 h-3 w-3" />
|
||||
{{ t("dangerDialog.cancel") }}
|
||||
<Minimize2 v-if="executing" class="mr-1 h-3 w-3" />
|
||||
<X v-else class="mr-1 h-3 w-3" />
|
||||
{{ executing ? t("exportProgress.minimize") : t("dangerDialog.cancel") }}
|
||||
</Button>
|
||||
<template v-if="currentStep === 'config'">
|
||||
<Button variant="default" size="sm" class="h-7 text-xs" :disabled="!hasSelectedTables" @click="doGenerate">
|
||||
|
||||
@@ -166,8 +166,11 @@ import {
|
||||
downloadBinaryCellPayload,
|
||||
formatBinaryCellByteSize,
|
||||
binaryCellUtf8Text,
|
||||
hasUnsafeOpaqueAggregateStatePredicate,
|
||||
isBlobCellColumnType,
|
||||
isBinaryCellColumnType,
|
||||
isOpaqueAggregateStateColumnType,
|
||||
mergeOpaqueReadonlyColumnIndexes,
|
||||
openBinaryCellFile,
|
||||
parseBinaryCellBytes,
|
||||
retainBinaryCellDownloadMenuForHover,
|
||||
@@ -250,6 +253,8 @@ import {
|
||||
buildColumnValueFilterCondition,
|
||||
buildColumnValuesFilterCondition,
|
||||
combineWhereInputs,
|
||||
formatFilterRawValue,
|
||||
formatFilterRawValues,
|
||||
filterModeHasCompleteValue,
|
||||
filterModeIsSupportedForDatabase,
|
||||
filterModeNeedsValue,
|
||||
@@ -326,6 +331,8 @@ import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifec
|
||||
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
|
||||
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
|
||||
import { createDataGridFilterConditionCache, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import { DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT, useDataGridDistinctValueLoader } from "@/composables/useDataGridDistinctValueLoader";
|
||||
import { dataGridDistinctValueKey, dataGridNullSuggestionFilterMode, toggleAllDataGridDistinctValueOptions, type DataGridDistinctValueSuggestionState, type DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import { cloneDataGridStructuredFilterRules, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState, type DataGridCachedServerColumnFilter, type DataGridStructuredFilterCacheState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import { createDataGridSearchScopeKey } from "@/lib/dataGrid/dataGridSearchStatePersistence";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
@@ -374,7 +381,7 @@ import { useDataGridColumnFormatter } from "@/composables/useDataGridColumnForma
|
||||
import { useDataGridTableMetadataLoaders } from "@/composables/useDataGridTableMetadataLoaders";
|
||||
import { DATA_GRID_SERVER_COLUMN_FILTER_LIMIT, useDataGridColumnFilters } from "@/composables/useDataGridColumnFilters";
|
||||
import { useDataGridLargeValues } from "@/composables/useDataGridLargeValues";
|
||||
import { useSalesforceSaveConfirmation } from "@/composables/useSalesforceSaveConfirmation";
|
||||
import { useDataGridSaveConfirmation } from "@/composables/useDataGridSaveConfirmation";
|
||||
|
||||
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
|
||||
const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue"));
|
||||
@@ -642,6 +649,10 @@ let preservedSelectionOnNextResult: {
|
||||
selection: PersistedDataGridSelection;
|
||||
sourceResult: QueryResult;
|
||||
} | null = null;
|
||||
let preservedTransposeRecordOnNextResult: {
|
||||
selection: PersistedDataGridSelection;
|
||||
sourceResult: QueryResult;
|
||||
} | null = null;
|
||||
let preservedDetailsOnNextResult: {
|
||||
sideCell?: PersistedDataGridSelection;
|
||||
sideCellHasPendingDraft?: boolean;
|
||||
@@ -1114,8 +1125,6 @@ const conditionHistoryScope = computed(() => ({
|
||||
tableName: props.tableMeta?.tableName,
|
||||
}));
|
||||
type LocalFilterMode = "local" | "server";
|
||||
type LocalFilterOption = DataGridLocalFilterOption;
|
||||
|
||||
type LocalColumnFilterDraft = {
|
||||
columnIndex: number;
|
||||
values: Set<string>;
|
||||
@@ -1161,11 +1170,6 @@ let localFilterResizeStartWidth = LOCAL_FILTER_POPOVER_DEFAULT_WIDTH;
|
||||
let localFilterResizeStartOffsetX = 0;
|
||||
let localFilterResizeStartLeft = 0;
|
||||
let localFilterResizeStartRight = 0;
|
||||
const serverFilterLoading = ref(false);
|
||||
const serverFilterError = ref("");
|
||||
const serverFilterOptions = ref<LocalFilterOption[]>([]);
|
||||
const serverFilterLimited = ref(false);
|
||||
const serverFilterValueByKey = ref<Map<string, CellValue>>(new Map());
|
||||
const serverColumnFilters = ref<Record<number, DataGridCachedServerColumnFilter>>({});
|
||||
let getGridNewRows: () => readonly (readonly CellValue[])[] = () => [];
|
||||
let getGridRowData: (row: CellValue[], sourceIndex: number) => readonly CellValue[] = (row) => row;
|
||||
@@ -1269,6 +1273,34 @@ const filterBuilderOpen = filterBuilder.open;
|
||||
const filterBuilderColumnSearch = filterBuilder.columnSearch;
|
||||
const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns;
|
||||
const appliedStructuredWhereInput = filterBuilder.appliedWhereInput;
|
||||
const filterValueSuggestionRuleId = ref<string>();
|
||||
const filterValueSuggestionTarget = ref<DataGridDistinctValueSuggestionTarget>();
|
||||
const filterValueSuggestionSearch = ref("");
|
||||
const filterValueSuggestionDraftValues = ref(new Map<string, CellValue>());
|
||||
const filterValueSuggestionLoader = useDataGridDistinctValueLoader({
|
||||
scopeIdentity: structuredFilterScopeKey,
|
||||
getConnectionId: () => props.connectionId,
|
||||
getExecutionDatabase: () => props.executionDatabase ?? props.database ?? "",
|
||||
getSchema: () => props.schema,
|
||||
getDatabaseType: () => resolvedDatabaseType.value,
|
||||
getConnectionConfig: () => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined),
|
||||
getIdentifierQuote: () => connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
getGlobalQueryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
|
||||
waitForTableMeta,
|
||||
formatValue: (value, columnIndex) => formatCellCached(value, columnIndex),
|
||||
keyForValue: dataGridDistinctValueKey,
|
||||
});
|
||||
const filterValueSuggestionState = computed<DataGridDistinctValueSuggestionState>(() => ({
|
||||
ruleId: filterValueSuggestionRuleId.value,
|
||||
target: filterValueSuggestionTarget.value,
|
||||
search: filterValueSuggestionSearch.value,
|
||||
options: filterValueSuggestionLoader.options.value,
|
||||
loading: filterValueSuggestionLoader.loading.value,
|
||||
error: filterValueSuggestionLoader.error.value,
|
||||
limited: filterValueSuggestionLoader.limited.value,
|
||||
limit: DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT,
|
||||
selectedKeys: new Set(filterValueSuggestionDraftValues.value.keys()),
|
||||
}));
|
||||
// Structured filter rules are restored asynchronously. A tab-switch snapshot's
|
||||
// probe includes the applied condition, so restoring before this hydration
|
||||
// settles would reject an otherwise valid snapshot and never retry it.
|
||||
@@ -1301,11 +1333,6 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
|
||||
localFilterOpenColumn,
|
||||
localFilterSearch,
|
||||
localFilterDraft,
|
||||
serverFilterLoading,
|
||||
serverFilterError,
|
||||
serverFilterOptions,
|
||||
serverFilterLimited,
|
||||
serverFilterValueByKey,
|
||||
serverColumnFilters,
|
||||
},
|
||||
getResult: () => props.result,
|
||||
@@ -1313,6 +1340,7 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
|
||||
getConnectionId: () => props.connectionId,
|
||||
getSchema: () => props.schema,
|
||||
getExecutionDatabase: () => props.executionDatabase ?? props.database ?? "",
|
||||
scopeIdentity: structuredFilterScopeKey,
|
||||
resolvedDatabaseType,
|
||||
canUseWhereSearch,
|
||||
canUseServerColumnFilter,
|
||||
@@ -1321,6 +1349,7 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
|
||||
whereFilterInput,
|
||||
getConnectionConfig: () => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined),
|
||||
getIdentifierQuote: () => connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
getGlobalQueryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
|
||||
getNewRows: () => getGridNewRows(),
|
||||
getRowData: (row, sourceIndex) => getGridRowData(row, sourceIndex),
|
||||
formatValue: formatCellCached,
|
||||
@@ -1352,6 +1381,10 @@ const {
|
||||
toggleLocalFilterSort,
|
||||
localFilterTypedValue,
|
||||
canApplyTypedLocalFilterValue,
|
||||
serverFilterLoading,
|
||||
serverFilterError,
|
||||
serverFilterLimited,
|
||||
resetDistinctValueCache,
|
||||
openLocalFilter,
|
||||
closeLocalFilter,
|
||||
toggleLocalFilterValue,
|
||||
@@ -1525,6 +1558,89 @@ async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Pro
|
||||
);
|
||||
}
|
||||
|
||||
function closeFilterValueSuggestions() {
|
||||
filterValueSuggestionRuleId.value = undefined;
|
||||
filterValueSuggestionTarget.value = undefined;
|
||||
filterValueSuggestionSearch.value = "";
|
||||
filterValueSuggestionDraftValues.value = new Map();
|
||||
filterValueSuggestionLoader.reset();
|
||||
}
|
||||
|
||||
function filterValueSuggestionRule(): StructuredFilterRule | undefined {
|
||||
return structuredFilterRules.value.find((rule) => rule.id === filterValueSuggestionRuleId.value);
|
||||
}
|
||||
|
||||
function filterValueSuggestionColumnIndex(columnName: string): number {
|
||||
const exact = props.result.columns.indexOf(columnName);
|
||||
if (exact >= 0) return exact;
|
||||
const normalized = columnName.toLowerCase();
|
||||
return props.result.columns.findIndex((column) => column.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
function filterValueSuggestionRequest(searchValue = filterValueSuggestionSearch.value) {
|
||||
const rule = filterValueSuggestionRule();
|
||||
if (!rule) return undefined;
|
||||
const columnIndex = filterValueSuggestionColumnIndex(rule.columnName);
|
||||
if (columnIndex < 0) return undefined;
|
||||
return {
|
||||
columnIndex,
|
||||
columnName: rule.columnName,
|
||||
searchValue,
|
||||
limit: DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT,
|
||||
includeCounts: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function openFilterValueSuggestions(ruleId: string, target: DataGridDistinctValueSuggestionTarget) {
|
||||
const rule = structuredFilterRules.value.find((item) => item.id === ruleId);
|
||||
if (!rule || rule.disabled || !rule.columnName || !filterModeNeedsValue(rule.mode)) return;
|
||||
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName)?.columnInfo;
|
||||
const currentValues = filterModeUsesList(rule.mode) ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : [];
|
||||
filterValueSuggestionRuleId.value = ruleId;
|
||||
filterValueSuggestionTarget.value = target;
|
||||
filterValueSuggestionSearch.value = "";
|
||||
filterValueSuggestionDraftValues.value = new Map(currentValues.filter((value) => value === null || typeof value !== "object").map((value): [string, CellValue] => [dataGridDistinctValueKey(value, columnInfo), value]));
|
||||
filterValueSuggestionLoader.reset();
|
||||
const request = filterValueSuggestionRequest();
|
||||
if (request) await filterValueSuggestionLoader.load(request);
|
||||
}
|
||||
|
||||
function updateFilterValueSuggestionSearch(value: string) {
|
||||
filterValueSuggestionSearch.value = value;
|
||||
const request = filterValueSuggestionRequest(value);
|
||||
if (request) filterValueSuggestionLoader.schedule(request);
|
||||
}
|
||||
|
||||
function selectFilterValueSuggestion(option: DataGridLocalFilterOption) {
|
||||
const rule = filterValueSuggestionRule();
|
||||
const target = filterValueSuggestionTarget.value;
|
||||
if (!rule || !target) return;
|
||||
if (option.value === null) {
|
||||
filterBuilder.updateRule(rule.id, { mode: dataGridNullSuggestionFilterMode(rule.mode), rawValue: "", rawEndValue: "" });
|
||||
} else {
|
||||
filterBuilder.updateRule(rule.id, target === "end" ? { rawEndValue: formatFilterRawValue(option.value) } : { rawValue: formatFilterRawValue(option.value) });
|
||||
}
|
||||
closeFilterValueSuggestions();
|
||||
}
|
||||
|
||||
function toggleFilterValueSuggestion(option: DataGridLocalFilterOption) {
|
||||
const next = new Map(filterValueSuggestionDraftValues.value);
|
||||
if (next.has(option.key)) next.delete(option.key);
|
||||
else next.set(option.key, option.value);
|
||||
filterValueSuggestionDraftValues.value = next;
|
||||
}
|
||||
|
||||
function toggleAllFilterValueSuggestions() {
|
||||
filterValueSuggestionDraftValues.value = toggleAllDataGridDistinctValueOptions(filterValueSuggestionDraftValues.value, filterValueSuggestionLoader.options.value);
|
||||
}
|
||||
|
||||
function applyFilterValueSuggestions() {
|
||||
const rule = filterValueSuggestionRule();
|
||||
if (!rule || !filterModeUsesList(rule.mode)) return;
|
||||
filterBuilder.updateRule(rule.id, { rawValue: formatFilterRawValues([...filterValueSuggestionDraftValues.value.values()]) });
|
||||
closeFilterValueSuggestions();
|
||||
}
|
||||
|
||||
function persistStructuredFilterState() {
|
||||
saveDataGridStructuredFilterState(structuredFilterCacheKey.value, {
|
||||
scopeKey: structuredFilterScopeKey.value,
|
||||
@@ -1584,10 +1700,12 @@ function addStructuredFilterRule() {
|
||||
}
|
||||
|
||||
function removeStructuredFilterRule(ruleId: string) {
|
||||
if (filterValueSuggestionRuleId.value === ruleId) closeFilterValueSuggestions();
|
||||
filterBuilder.removeRule(ruleId);
|
||||
}
|
||||
|
||||
function updateStructuredFilterRule(ruleId: string, patch: Partial<StructuredFilterRule>) {
|
||||
if (filterValueSuggestionRuleId.value === ruleId) closeFilterValueSuggestions();
|
||||
filterBuilder.updateRule(ruleId, patch);
|
||||
}
|
||||
|
||||
@@ -1600,6 +1718,7 @@ function updateTextFilterPanelHeight(height: number) {
|
||||
}
|
||||
|
||||
function resetStructuredFilters() {
|
||||
closeFilterValueSuggestions();
|
||||
filterBuilder.reset();
|
||||
}
|
||||
|
||||
@@ -1707,6 +1826,7 @@ function copyFilterSqlPreview() {
|
||||
watch([structuredFilterCacheKey, structuredFilterScopeKey], loadStructuredFilterStateForScope, { immediate: true });
|
||||
|
||||
watch(filterEditorView, (view) => {
|
||||
closeFilterValueSuggestions();
|
||||
filterBuilderOpen.value = (view === "conditions" || view === "text") && settingsStore.editorSettings.dataGridKeepFilterEditorExpanded;
|
||||
if (view === "conditions" || view === "text") ensureStructuredFilterRule();
|
||||
});
|
||||
@@ -2266,7 +2386,7 @@ function scrollToColumnIndex(columnIndex: number) {
|
||||
|
||||
// --- Column resize composable ---
|
||||
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
|
||||
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "fill");
|
||||
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "content");
|
||||
const tableFontFamily = computed(() => settingsStore.editorSettings.tableFontFamily);
|
||||
const columnWidthCacheKey = computed(() => props.columnWidthCacheKey?.trim() || props.cacheKey?.trim() || undefined);
|
||||
const columnStructureSignature = computed(() => createDataGridColumnStructureSignature(props.result.columns, props.result.column_types));
|
||||
@@ -3274,6 +3394,7 @@ const resultSourceColumns = computed(() => props.result.columns.map((column, ind
|
||||
const canEditExistingRows = computed(
|
||||
() => !!props.customSaveHandler || (canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []) && hasCompleteTdengineRowIdentity(props.databaseType, props.tableMeta?.primaryKeys ?? [], resultSourceColumns.value)),
|
||||
);
|
||||
const canUpdateExistingRows = computed(() => canEditExistingRows.value && props.customSaveHandler?.canUpdate !== false);
|
||||
const customReadonlyColumns = computed(() => new Set((props.customSaveHandler?.readonlyColumns ?? []).map((column) => column.toLowerCase())));
|
||||
const hasDataGridSaveTarget = computed(() => !!props.tableMeta || !!props.customSaveHandler);
|
||||
const hasDataGridInsertTarget = computed(() => {
|
||||
@@ -3813,22 +3934,22 @@ async function refreshSavedRows(request: { dirtyRows: ReadonlyMap<number, Readon
|
||||
return true;
|
||||
}
|
||||
|
||||
// Salesforce applies one REST call per record with no transaction, so every grid
|
||||
// save is reviewed here first. The identity lines are advisory only — Salesforce
|
||||
// enforces the real object/field permissions, and a failed identity lookup just
|
||||
// omits the line rather than blocking the write.
|
||||
// Non-transactional or specialized mutations can require a final review of the
|
||||
// exact statements after changes have been staged. Salesforce and guarded
|
||||
// InfluxDB 1.x deletion share the promise-backed dialog state below.
|
||||
const {
|
||||
open: salesforceSaveConfirmOpen,
|
||||
updates: salesforceSaveConfirmUpdates,
|
||||
inserts: salesforceSaveConfirmInserts,
|
||||
deletes: salesforceSaveConfirmDeletes,
|
||||
total: salesforceSaveConfirmTotal,
|
||||
targetLabel: salesforceSaveConfirmTarget,
|
||||
statements: salesforceSaveConfirmStatements,
|
||||
request: requestSalesforceSaveConfirmation,
|
||||
confirm: confirmSalesforceSave,
|
||||
} = useSalesforceSaveConfirmation();
|
||||
open: saveConfirmOpen,
|
||||
updates: saveConfirmUpdates,
|
||||
inserts: saveConfirmInserts,
|
||||
deletes: saveConfirmDeletes,
|
||||
total: saveConfirmTotal,
|
||||
targetLabel: saveConfirmTarget,
|
||||
statements: saveConfirmStatements,
|
||||
request: requestDataGridSaveConfirmation,
|
||||
confirm: confirmDataGridSave,
|
||||
} = useDataGridSaveConfirmation();
|
||||
const isSalesforceGrid = computed(() => resolvedDatabaseType.value === "salesforce");
|
||||
const isInfluxDbV1DeleteGrid = computed(() => props.customSaveHandler?.confirmation === "influxdb-v1-delete");
|
||||
const salesforceIdentity = computed(() => (isSalesforceGrid.value && props.connectionId ? connectionStore.salesforceCurrentUser(props.connectionId) : null));
|
||||
const salesforceIdentityLabel = computed(() => {
|
||||
const identity = salesforceIdentity.value;
|
||||
@@ -3837,9 +3958,9 @@ const salesforceIdentityLabel = computed(() => {
|
||||
});
|
||||
const salesforceSaveConfirmSummary = computed(() => {
|
||||
const parts: string[] = [];
|
||||
if (salesforceSaveConfirmUpdates.value > 0) parts.push(t("grid.salesforceSaveUpdates", { count: salesforceSaveConfirmUpdates.value }));
|
||||
if (salesforceSaveConfirmInserts.value > 0) parts.push(t("grid.salesforceSaveInserts", { count: salesforceSaveConfirmInserts.value }));
|
||||
if (salesforceSaveConfirmDeletes.value > 0) parts.push(t("grid.salesforceSaveDeletes", { count: salesforceSaveConfirmDeletes.value }));
|
||||
if (saveConfirmUpdates.value > 0) parts.push(t("grid.salesforceSaveUpdates", { count: saveConfirmUpdates.value }));
|
||||
if (saveConfirmInserts.value > 0) parts.push(t("grid.salesforceSaveInserts", { count: saveConfirmInserts.value }));
|
||||
if (saveConfirmDeletes.value > 0) parts.push(t("grid.salesforceSaveDeletes", { count: saveConfirmDeletes.value }));
|
||||
return parts.join(" · ");
|
||||
});
|
||||
// The save dialog names the profile alongside the user: writability comes from
|
||||
@@ -3849,7 +3970,7 @@ const salesforceIdentityProfile = computed(() => {
|
||||
return profileName ? t("grid.salesforceSaveProfile", { name: profileName }) : t("toolbar.salesforceIdentityUnknownProfile");
|
||||
});
|
||||
const salesforceSaveConfirmDetails = computed(() => {
|
||||
const lines = [salesforceSaveConfirmSummary.value, t("grid.salesforceSaveTarget", { object: salesforceSaveConfirmTarget.value || t("grid.salesforceSaveUnknownObject") })];
|
||||
const lines = [salesforceSaveConfirmSummary.value, t("grid.salesforceSaveTarget", { object: saveConfirmTarget.value || t("grid.salesforceSaveUnknownObject") })];
|
||||
if (salesforceIdentity.value) {
|
||||
const identity = { user: salesforceIdentityLabel.value, profile: salesforceIdentityProfile.value };
|
||||
if (salesforceIdentity.value.isAdmin === true) lines.push(t("grid.salesforceSaveAdminIdentity", identity));
|
||||
@@ -3858,9 +3979,13 @@ const salesforceSaveConfirmDetails = computed(() => {
|
||||
}
|
||||
return lines.filter((line) => !!line).join("\n");
|
||||
});
|
||||
const salesforceSaveConfirmSql = computed(() => salesforceSaveConfirmStatements.value.join("\n"));
|
||||
watch(salesforceSaveConfirmOpen, (isOpen) => {
|
||||
if (!isOpen || !props.connectionId) return;
|
||||
const saveConfirmSql = computed(() => saveConfirmStatements.value.join("\n"));
|
||||
const saveConfirmTitle = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteConfirmTitle") : t("grid.salesforceSaveConfirmTitle")));
|
||||
const saveConfirmMessage = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteConfirmMessage", { count: saveConfirmDeletes.value }) : t("grid.salesforceSaveConfirmMessage", { count: saveConfirmTotal.value })));
|
||||
const saveConfirmDetails = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteTarget", { measurement: saveConfirmTarget.value || "—" }) : salesforceSaveConfirmDetails.value));
|
||||
const saveConfirmLabel = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteConfirm") : t("grid.salesforceSaveConfirm")));
|
||||
watch(saveConfirmOpen, (isOpen) => {
|
||||
if (!isOpen || !isSalesforceGrid.value || !props.connectionId) return;
|
||||
void connectionStore.loadSalesforceCurrentUser(props.connectionId);
|
||||
});
|
||||
|
||||
@@ -3873,7 +3998,7 @@ const editor = useDataGridEditor({
|
||||
tableMeta: computed(() => props.tableMeta),
|
||||
sourceColumns: computed(() => props.sourceColumns),
|
||||
joinedWriteTargets: computed(() => props.joinedWriteTargets),
|
||||
readonlyColumnIndexes: computed(() => (props.readonlyColumnIndexes ? new Set(props.readonlyColumnIndexes) : undefined)),
|
||||
readonlyColumnIndexes: computed(() => mergeOpaqueReadonlyColumnIndexes(props.readonlyColumnIndexes, allColumnTypes.value)),
|
||||
canEditExistingRows,
|
||||
onExecuteSql: computed(() => props.onExecuteSql),
|
||||
customSaveHandler: computed(() => props.customSaveHandler),
|
||||
@@ -3888,8 +4013,7 @@ const editor = useDataGridEditor({
|
||||
rowStatusFilter,
|
||||
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
|
||||
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
|
||||
// Only Salesforce opts in: its writes cannot be rolled back once issued.
|
||||
confirmSaveRequest: computed(() => (isSalesforceGrid.value ? requestSalesforceSaveConfirmation : undefined)),
|
||||
confirmSaveRequest: computed(() => (isSalesforceGrid.value || isInfluxDbV1DeleteGrid.value ? requestDataGridSaveConfirmation : undefined)),
|
||||
includeDatabaseNameInSaveSql: computed(() => settingsStore.editorSettings.generateSqlIncludeDatabaseName),
|
||||
initialEditColumn: firstVisibleColumnIndex,
|
||||
cellEditorText: cellEditorTextForValue,
|
||||
@@ -4083,7 +4207,9 @@ function canEditRowItem(item: RowItem | undefined): boolean {
|
||||
}
|
||||
|
||||
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
|
||||
if (!canEditRowItem(item) || !canEditColumn(columnIndex)) return false;
|
||||
if (!canEditRowItem(item) || !item || !canEditColumn(columnIndex)) return false;
|
||||
if (isOpaqueAggregateStateColumnType(allColumnTypes.value[columnIndex])) return false;
|
||||
if (!item.isNew && !item.isDraft && !canUpdateExistingRows.value) return false;
|
||||
if (isSavingNewRow(item)) return false;
|
||||
const column = props.result.columns[columnIndex] ?? "";
|
||||
if (customReadonlyColumns.value.has(column.toLowerCase())) return false;
|
||||
@@ -4346,7 +4472,8 @@ function isDecimalColumnType(dataType: string): boolean {
|
||||
|
||||
function canDeleteRowItem(item: RowItem | undefined): boolean {
|
||||
if (!item) return false;
|
||||
return canDeleteGridRowItem({
|
||||
if (!item.isNew && canUseKeylessRowPredicate(props.databaseType, props.tableMeta?.primaryKeys ?? []) && hasUnsafeOpaqueAggregateStatePredicate(allColumnTypes.value, item.data)) return false;
|
||||
const canDelete = canDeleteGridRowItem({
|
||||
editable: !!props.editable && canDeleteRows.value,
|
||||
isDraft: !!item.isDraft,
|
||||
isDeleted: item.isDeleted,
|
||||
@@ -4354,6 +4481,8 @@ function canDeleteRowItem(item: RowItem | undefined): boolean {
|
||||
canEditExistingRows: canEditExistingRows.value && canDeleteExistingRows.value,
|
||||
isSavingNewRow: isSavingNewRow(item),
|
||||
});
|
||||
if (!canDelete || item.isNew || !props.customSaveHandler?.canDeleteRow) return canDelete;
|
||||
return item.sourceIndex !== undefined && props.customSaveHandler.canDeleteRow(item.sourceIndex, item.data);
|
||||
}
|
||||
|
||||
function resetInfiniteScrollState() {
|
||||
@@ -5322,6 +5451,20 @@ function restoreSelectionAfterRefresh(snapshot: PersistedDataGridSelection) {
|
||||
});
|
||||
}
|
||||
|
||||
function restoreTransposeRecordAfterRefresh(snapshot: PersistedDataGridSelection) {
|
||||
const restored = restoreDataGridSelection({
|
||||
snapshot,
|
||||
columns: props.result.columns,
|
||||
sourceColumns: props.sourceColumns,
|
||||
rows: props.result.rows,
|
||||
visibleColumnIndexes: visibleColumnIndexes.value,
|
||||
displayItems: displayItems.value,
|
||||
});
|
||||
if (restored?.kind !== "rows") return;
|
||||
transposeRowIndex.value = restored.scrollRowIndex;
|
||||
nextTick(() => scrollTransposeRecordIntoView(restored.scrollRowIndex));
|
||||
}
|
||||
|
||||
/** Bounded settling envelope for a replayed tab-switch viewport. */
|
||||
const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;
|
||||
let viewSnapshotRestoreFrame = 0;
|
||||
@@ -5743,7 +5886,11 @@ function affectedRowIds(): number[] {
|
||||
}
|
||||
|
||||
function deletableRowIds(rowIds: number[]): number[] {
|
||||
return rowIds.filter((rowId) => canDeleteRowItem(getRowItem(rowId)));
|
||||
const eligible = rowIds.filter((rowId) => canDeleteRowItem(getRowItem(rowId)));
|
||||
// A custom row-safety predicate is an all-or-nothing contract. Never silently
|
||||
// drop an unsafe row from a multi-row request while deleting its neighbors.
|
||||
if (props.customSaveHandler?.canDeleteRow && eligible.length !== rowIds.length) return [];
|
||||
return eligible;
|
||||
}
|
||||
|
||||
function exportSelectedRowsCsv() {
|
||||
@@ -6281,6 +6428,14 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
|
||||
toast(t("grid.largeValueLocalSortUnavailable"), 5000);
|
||||
return;
|
||||
}
|
||||
if (showTranspose.value) {
|
||||
const selection = captureCurrentSelectionForRefresh();
|
||||
preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;
|
||||
const activeRecord = transposeRowIndex.value === null ? undefined : displayItemAt(transposeRowIndex.value);
|
||||
const activeRecordSelection = captureRowTargetForRefresh(activeRecord?.id ?? null);
|
||||
preservedTransposeRecordOnNextResult = activeRecordSelection ? { selection: activeRecordSelection, sourceResult: props.result } : null;
|
||||
preserveTransposeOnNextResult.value = true;
|
||||
}
|
||||
if (mode === "database" && (infiniteScrollEnabled.value || loadAllRowsActive.value)) {
|
||||
resetInfiniteScrollState();
|
||||
} else {
|
||||
@@ -6496,11 +6651,11 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string
|
||||
}
|
||||
|
||||
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string {
|
||||
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
|
||||
if (props.mongoCollectionGrid) {
|
||||
const documentGridText = mongoDocumentGridDisplayText(value);
|
||||
const documentGridText = mongoDocumentGridDisplayText(value, formatter);
|
||||
if (documentGridText !== undefined) return documentGridText;
|
||||
}
|
||||
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
|
||||
if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) {
|
||||
const display = formatForeignKeyCellDisplay(value, columnIndex);
|
||||
return limitDisplay ? limitDataGridCellDisplay(display, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : display;
|
||||
@@ -8370,7 +8525,7 @@ function selectedRangeTargetsOnlyDraftRow(): boolean {
|
||||
return displayItemAt(range.startRow)?.isDraft === true;
|
||||
}
|
||||
|
||||
const replaceAvailable = computed(() => !!props.editable && hasDataGridSaveTarget.value && canEditExistingRows.value && !resolvedConnectionConfig.value?.read_only && !isConditionalUpdateActive.value);
|
||||
const replaceAvailable = computed(() => !!props.editable && hasDataGridSaveTarget.value && canUpdateExistingRows.value && !resolvedConnectionConfig.value?.read_only && !isConditionalUpdateActive.value);
|
||||
const replaceResolving = ref(false);
|
||||
const replaceBusy = computed(() => replaceResolving.value || isSaving.value || gridSurfaceBusy.value || props.loading === true);
|
||||
|
||||
@@ -10134,6 +10289,8 @@ watch(
|
||||
// check has to run before the markers are consumed below.
|
||||
const inPlaceRefreshPending = preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value;
|
||||
preservedSelectionOnNextResult = null;
|
||||
const transposeRecordSnapshot = preservedTransposeRecordOnNextResult?.selection;
|
||||
preservedTransposeRecordOnNextResult = null;
|
||||
const viewportAnchorSnapshot = preservedViewportAnchorOnNextResult?.anchor;
|
||||
preservedViewportAnchorOnNextResult = null;
|
||||
const detailsSnapshot = preservedDetailsOnNextResult;
|
||||
@@ -10163,6 +10320,8 @@ watch(
|
||||
}
|
||||
return;
|
||||
}
|
||||
resetDistinctValueCache();
|
||||
filterValueSuggestionLoader.reset({ clearCache: true });
|
||||
// A non-append result replaces the whole data set, so a running "load all" is over.
|
||||
loadAllRowsActive.value = false;
|
||||
// The replacement also invalidates the all-loaded marker: a filter change or
|
||||
@@ -10195,6 +10354,7 @@ watch(
|
||||
}
|
||||
exitTransaction();
|
||||
if (selectionSnapshot) restoreSelectionAfterRefresh(selectionSnapshot);
|
||||
if (transposeRecordSnapshot) restoreTransposeRecordAfterRefresh(transposeRecordSnapshot);
|
||||
if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);
|
||||
if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot);
|
||||
},
|
||||
@@ -11918,6 +12078,8 @@ function exportSubmenu(): ContextMenuItem {
|
||||
const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
const row = contextRowItem.value;
|
||||
const rowLabels = rowActionLabels();
|
||||
const rowIdsForDelete = isMultiRow.value ? affectedRowIds() : row ? [row.id] : [];
|
||||
const deletableContextRowIds = deletableRowIds(rowIdsForDelete);
|
||||
const hasEditableSelection = selectionHasEditableCells();
|
||||
const selectedColumnCount = selectedVisibleColumnIndexes().length;
|
||||
const gridSnapshotContext = contextHeaderColumn.value && hasColumnSelection.value ? "columns" : contextCell.value?.col === -1 && affectedRowIds().length > 0 ? "rows" : contextCell.value && hasCellSelection.value && selectedCellMatrix.value ? "cells" : null;
|
||||
@@ -12087,13 +12249,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
hasRow: !!row,
|
||||
canClone: !!row && canInsertRows.value && !row.isDraft,
|
||||
deleted: !!row?.isDeleted,
|
||||
canDelete: !!row && canDeleteRowItem(row),
|
||||
canDelete: deletableContextRowIds.length > 0,
|
||||
labels: rowLabels,
|
||||
icons: { clone: CopyPlus, restore: Undo2, delete: Trash2 },
|
||||
actions: {
|
||||
clone: () => void (isMultiRow.value ? cloneRows(affectedRowIds()) : row && cloneRow(row.id)),
|
||||
restore: () => (isMultiRow.value ? restoreRows(affectedRowIds()) : row && restoreRow(row.id)),
|
||||
delete: () => (isMultiRow.value ? requestDeleteRows(deletableRowIds(affectedRowIds())) : row && requestDeleteRow(row.id)),
|
||||
delete: () => {
|
||||
if (deletableContextRowIds.length > 0) requestDeleteRows(deletableContextRowIds);
|
||||
},
|
||||
},
|
||||
}),
|
||||
[exportSubmenu()],
|
||||
@@ -12232,6 +12396,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
:filtered-columns="filteredFilterBuilderColumnOptions"
|
||||
:mode-options="filterModeOptions"
|
||||
:column-search="filterBuilderColumnSearch"
|
||||
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
|
||||
:apply-where="applyWhereFilter"
|
||||
:apply-order-by="applyOrderBySearch"
|
||||
:clear-order-by="clearOrderByInput"
|
||||
@@ -12247,6 +12412,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@move-rule="moveStructuredFilterRule"
|
||||
@update-rule="updateStructuredFilterRule"
|
||||
@clear-local-filter="clearLocalFilter"
|
||||
@open-value-suggestions="openFilterValueSuggestions"
|
||||
@close-value-suggestions="closeFilterValueSuggestions"
|
||||
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
|
||||
@select-value-suggestion="selectFilterValueSuggestion"
|
||||
@toggle-value-suggestion="toggleFilterValueSuggestion"
|
||||
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
|
||||
@apply-value-suggestions="applyFilterValueSuggestions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -12397,6 +12569,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@add-rule="addStructuredFilterRule"
|
||||
@apply="applyStructuredFilters"
|
||||
:apply-only-busy="applyingOnlyStructuredFilter || isApplyingWhere"
|
||||
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
|
||||
@apply-only="applyOnlyStructuredFilter"
|
||||
@reset="resetStructuredFilters"
|
||||
@clear="clearAllFilters"
|
||||
@@ -12404,6 +12577,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@remove-rule="removeStructuredFilterRule"
|
||||
@move-rule="moveStructuredFilterRule"
|
||||
@update-rule="updateStructuredFilterRule"
|
||||
@open-value-suggestions="openFilterValueSuggestions"
|
||||
@close-value-suggestions="closeFilterValueSuggestions"
|
||||
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
|
||||
@select-value-suggestion="selectFilterValueSuggestion"
|
||||
@toggle-value-suggestion="toggleFilterValueSuggestion"
|
||||
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
|
||||
@apply-value-suggestions="applyFilterValueSuggestions"
|
||||
/>
|
||||
<DataGridTextFilterWorkbench
|
||||
v-if="canUseWhereSearch && filterEditorView === 'text' && filterBuilderOpen"
|
||||
@@ -12415,6 +12595,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
:mode-options="filterModeOptions"
|
||||
:column-search="filterBuilderColumnSearch"
|
||||
:disabled="!canUseWhereSearch"
|
||||
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
|
||||
@update:height="updateTextFilterPanelHeight"
|
||||
@update:column-search="filterBuilderColumnSearch = $event"
|
||||
@ensure-rule="ensureStructuredFilterRule"
|
||||
@@ -12428,6 +12609,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@remove-rule="removeStructuredFilterRule"
|
||||
@move-rule="moveStructuredFilterRule"
|
||||
@update-rule="updateStructuredFilterRule"
|
||||
@open-value-suggestions="openFilterValueSuggestions"
|
||||
@close-value-suggestions="closeFilterValueSuggestions"
|
||||
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
|
||||
@select-value-suggestion="selectFilterValueSuggestion"
|
||||
@toggle-value-suggestion="toggleFilterValueSuggestion"
|
||||
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
|
||||
@apply-value-suggestions="applyFilterValueSuggestions"
|
||||
/>
|
||||
<!-- Truncation warning banner -->
|
||||
<div v-if="showTruncationWarning" class="shrink-0 px-3 py-1 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
|
||||
@@ -12517,6 +12705,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
<div
|
||||
v-for="recordIndex in activeTransposeRecordIndexes"
|
||||
:key="`transpose-head-${recordIndex}`"
|
||||
data-grid-transpose-record-header
|
||||
:data-grid-transpose-record-index="recordIndex"
|
||||
class="shrink-0 border-r border-border px-2 py-1.5 text-left tabular-nums relative"
|
||||
:class="{
|
||||
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
|
||||
@@ -12547,6 +12737,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
<LightTooltip :text="transposeFieldTitle(item)" side="right" :side-offset="6" :delay="250" :open-on-focus="false" surface="popover">
|
||||
<div
|
||||
data-native-clipboard
|
||||
:data-grid-transpose-column-index="visibleColumnIndexes[index]"
|
||||
class="sticky left-0 z-10 flex shrink-0 flex-col items-start justify-center overflow-hidden border-r border-border bg-background px-3 py-0"
|
||||
:class="{
|
||||
'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes[index],
|
||||
@@ -12555,11 +12746,43 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
}"
|
||||
:style="{ width: `${transposePinnedWidth}px` }"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<span class="flex w-full min-w-0 items-center gap-1 overflow-hidden pr-5">
|
||||
<KeyRound v-if="transposeColumnIndexKind(item.column) === 'primary'" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass('primary')" :title="transposeColumnIndexText('primary')" />
|
||||
<Hash v-else-if="transposeColumnIndexKind(item.column)" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass(transposeColumnIndexKind(item.column)!)" :title="transposeColumnIndexText(transposeColumnIndexKind(item.column)!)" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium leading-4">{{ item.column }}</span>
|
||||
</span>
|
||||
<LightDropdownMenu
|
||||
v-if="headerColumnSortable(visibleColumnIndexes[index])"
|
||||
:items="sortMenuItems(item.column, visibleColumnIndexes[index])"
|
||||
:open="headerSortMenuOpenColumn === visibleColumnIndexes[index]"
|
||||
:selected-value="selectedSortMenuValue(item.column, visibleColumnIndexes[index])"
|
||||
check-position="none"
|
||||
align="end"
|
||||
content-class="w-max min-w-28 p-0.5"
|
||||
item-class="gap-1 rounded-none px-1.5 py-0.5 text-xs"
|
||||
item-icon-class="h-3 w-3"
|
||||
:match-trigger-width="false"
|
||||
@update:open="(value: boolean) => (headerSortMenuOpenColumn = value ? visibleColumnIndexes[index] : null)"
|
||||
@select="(value: string) => selectHeaderSort(value, item.column, visibleColumnIndexes[index])"
|
||||
>
|
||||
<template #trigger="{ open, toggle }">
|
||||
<button
|
||||
data-grid-transpose-sort
|
||||
type="button"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 shrink-0 items-center justify-center rounded"
|
||||
:class="columnIsSorted(item.column, visibleColumnIndexes[index]) ? 'bg-primary text-primary-foreground opacity-100 shadow-sm hover:bg-primary/90' : 'text-muted-foreground opacity-80 hover:bg-accent hover:text-foreground'"
|
||||
:title="t('grid.sort')"
|
||||
:aria-label="`${t('grid.sort')}: ${item.column}`"
|
||||
:aria-expanded="open"
|
||||
@mousedown.stop
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<ArrowUp v-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowDown v-else-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
|
||||
<span data-grid-transpose-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 select-none" :class="typeColorClass(item.type)" :title="item.type">
|
||||
{{ item.type }}
|
||||
@@ -12593,6 +12816,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
<div
|
||||
v-for="cell in item.values"
|
||||
:key="`${item.id}:${cell.recordIndex}`"
|
||||
data-grid-transpose-cell
|
||||
:data-grid-transpose-record-index="cell.recordIndex"
|
||||
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0"
|
||||
:class="[
|
||||
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
|
||||
@@ -14289,18 +14514,9 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDropAllMongoIndexes"
|
||||
/>
|
||||
<!-- Salesforce applies each record with its own REST call and cannot roll back, so the
|
||||
save is reviewed here first. Cancel (or closing) denies it and keeps the edits staged. -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="salesforceSaveConfirmOpen"
|
||||
:title="t('grid.salesforceSaveConfirmTitle')"
|
||||
:message="t('grid.salesforceSaveConfirmMessage', { count: salesforceSaveConfirmTotal })"
|
||||
:details-text="salesforceSaveConfirmDetails"
|
||||
:sql="salesforceSaveConfirmSql"
|
||||
:confirm-label="t('grid.salesforceSaveConfirm')"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmSalesforceSave"
|
||||
/>
|
||||
<!-- Specialized non-transactional saves are reviewed here first. Cancel (or closing)
|
||||
denies the request and keeps the pending rows staged. -->
|
||||
<DangerConfirmDialog v-model:open="saveConfirmOpen" :title="saveConfirmTitle" :message="saveConfirmMessage" :details-text="saveConfirmDetails" :sql="saveConfirmSql" :confirm-label="saveConfirmLabel" :close-on-confirm="false" @confirm="confirmDataGridSave" />
|
||||
<ImagePreviewDialog v-if="imagePreviewMounted" v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
|
||||
<component v-if="previewDialogOpen && previewDialogConfig" :is="previewDialogConfig.component" v-model:open="previewDialogOpen" v-bind="previewDialogConfig.props" />
|
||||
<ExportProgressDialog
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSettingsStore, type DataGridColumnWidthMode } from "@/stores/setting
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "fill");
|
||||
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "content");
|
||||
|
||||
function setColumnWidthMode(value: DataGridColumnWidthMode) {
|
||||
settingsStore.updateEditorSettings({ dataGridColumnWidthMode: value });
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Check, Database, Loader2, Search } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
search: string;
|
||||
options: DataGridLocalFilterOption[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
limited: boolean;
|
||||
limit: number;
|
||||
multiple?: boolean;
|
||||
selectedKeys?: Set<string>;
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [open: boolean];
|
||||
"update:search": [search: string];
|
||||
select: [option: DataGridLocalFilterOption];
|
||||
toggle: [option: DataGridLocalFilterOption];
|
||||
toggleAll: [];
|
||||
apply: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const allOptionsSelected = computed(() => props.options.length > 0 && props.options.every((option) => props.selectedKeys?.has(option.key)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="shrink-0" :class="props.compact ? 'h-6 w-6 rounded-none border-0 bg-transparent shadow-none' : 'h-7 w-7'" :disabled="props.disabled" :title="t('grid.databaseValueFilter')" :aria-label="t('grid.databaseValueFilter')">
|
||||
<Database class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" class="w-80 gap-0 overflow-hidden p-0" @keydown.stop>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
:value="props.search"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('grid.searchValues')"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid border-b bg-muted/40 px-2 py-1 text-xs font-medium text-muted-foreground" :class="props.multiple ? 'grid-cols-[1.75rem_minmax(0,1fr)_3.5rem]' : 'grid-cols-[minmax(0,1fr)_3.5rem]'">
|
||||
<button
|
||||
v-if="props.multiple"
|
||||
type="button"
|
||||
data-distinct-value-select-all
|
||||
role="checkbox"
|
||||
class="flex h-4 w-4 items-center justify-center rounded border"
|
||||
:class="allOptionsSelected ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'"
|
||||
:disabled="!props.options.length"
|
||||
:aria-checked="allOptionsSelected"
|
||||
@click="emit('toggleAll')"
|
||||
>
|
||||
<Check v-if="allOptionsSelected" class="h-3 w-3 stroke-[3]" />
|
||||
</button>
|
||||
<span>{{ t("grid.value") }}</span>
|
||||
<span class="text-right">{{ t("grid.count") }}</span>
|
||||
</div>
|
||||
<div v-if="props.loading || props.error || props.limited" class="flex items-center gap-1.5 border-b px-2 py-1 text-[11px] text-muted-foreground">
|
||||
<Loader2 v-if="props.loading" class="h-3 w-3 animate-spin" />
|
||||
<span class="min-w-0 truncate">
|
||||
<template v-if="props.loading">{{ t("grid.loadingValues") }}</template>
|
||||
<template v-else-if="props.error">{{ props.error }}</template>
|
||||
<template v-else>{{ t("grid.serverValuesLimited", { count: props.limit }) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-0.5">
|
||||
<button
|
||||
v-for="option in props.options"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
class="grid w-full items-center px-2 py-1 text-left text-xs hover:bg-accent"
|
||||
:class="props.multiple ? 'grid-cols-[1.75rem_minmax(0,1fr)_3.5rem]' : 'grid-cols-[minmax(0,1fr)_3.5rem]'"
|
||||
@click="props.multiple ? emit('toggle', option) : emit('select', option)"
|
||||
>
|
||||
<span v-if="props.multiple" class="flex h-4 w-4 items-center justify-center rounded border" :class="props.selectedKeys?.has(option.key) ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'">
|
||||
<Check v-if="props.selectedKeys?.has(option.key)" class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="truncate font-mono">{{ option.label }}</span>
|
||||
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ option.count ?? "" }}</span>
|
||||
</button>
|
||||
<div v-if="!props.options.length && !props.loading && !props.error" class="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.multiple" class="flex justify-end gap-2 border-t bg-muted/40 px-2 py-1.5">
|
||||
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="emit('update:open', false)">
|
||||
{{ t("dangerDialog.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-7 px-2 text-xs" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -6,11 +6,14 @@ import { Button } from "@/components/ui/button";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import DataGridDistinctValuePopover from "@/components/grid/DataGridDistinctValuePopover.vue";
|
||||
import { filterModeNeedsValue, filterModeUsesList, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { resolveDataGridFilterRuleDropPlacement } from "@/lib/dataGrid/dataGridFilterRuleDrag";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
|
||||
const { t } = useI18n();
|
||||
const VALUE_SHORTCUT_HINT_STORAGE_KEY = "dbx-filter-builder-value-shortcut-hint-days";
|
||||
@@ -38,6 +41,7 @@ const props = withDefaults(
|
||||
showHeader?: boolean;
|
||||
showFooter?: boolean;
|
||||
layout?: "popover" | "panel" | "text";
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
}>(),
|
||||
{ showHeader: true, showFooter: true, layout: "popover" },
|
||||
);
|
||||
@@ -51,6 +55,13 @@ const emit = defineEmits<{
|
||||
move: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
"update:columnSearch": [value: string];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
const columnSearchInputs = new Map<string, HTMLInputElement>();
|
||||
const filterRuleElements = new Map<string, HTMLElement>();
|
||||
@@ -95,6 +106,19 @@ function usesExpandedLayout(mode: DataGridContextFilterMode) {
|
||||
return filterModeUsesList(mode) || filterModeUsesRange(mode);
|
||||
}
|
||||
|
||||
function canSuggestValues(rule: DataGridStructuredFilterRule) {
|
||||
return !!props.valueSuggestions && !!rule.columnName && !rule.disabled && filterModeNeedsValue(rule.mode);
|
||||
}
|
||||
|
||||
function valueSuggestionsOpen(rule: DataGridStructuredFilterRule, target: DataGridDistinctValueSuggestionTarget) {
|
||||
return props.valueSuggestions?.ruleId === rule.id && props.valueSuggestions.target === target;
|
||||
}
|
||||
|
||||
function updateValueSuggestionsOpen(rule: DataGridStructuredFilterRule, target: DataGridDistinctValueSuggestionTarget, open: boolean) {
|
||||
if (open) emit("openValueSuggestions", rule.id, target);
|
||||
else if (valueSuggestionsOpen(rule, target)) emit("closeValueSuggestions");
|
||||
}
|
||||
|
||||
function currentLocalDateKey() {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
@@ -621,6 +645,20 @@ function blurValueRule(id: string) {
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground">—</span>
|
||||
<Input
|
||||
:model-value="rule.rawEndValue"
|
||||
@@ -630,34 +668,82 @@ function blurValueRule(id: string) {
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'end')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'end', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="filterModeUsesList(rule.mode)">
|
||||
<Input
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValues')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value-list:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
:multiple="true"
|
||||
:selected-keys="props.valueSuggestions.selectedKeys"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@toggle="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all="emit('toggleAllValueSuggestions')"
|
||||
@apply="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="filterModeNeedsValue(rule.mode)">
|
||||
<Input
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderTextValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</template>
|
||||
<Input
|
||||
v-else-if="filterModeUsesList(rule.mode)"
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValues')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value-list:${rule.id}`)"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="filterModeNeedsValue(rule.mode)"
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderTextValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
|
||||
/>
|
||||
<span v-else class="px-1 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</span>
|
||||
</div>
|
||||
<div v-else-if="filterModeUsesRange(rule.mode)" class="col-start-2 col-span-3 flex gap-1.5">
|
||||
<Input
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-7 text-xs"
|
||||
class="h-7 min-w-0 flex-1 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderRangeStart')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@@ -667,9 +753,22 @@ function blurValueRule(id: string) {
|
||||
@compositionstart="startImeComposition(`value-start:${rule.id}`)"
|
||||
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.rawEndValue"
|
||||
class="h-7 text-xs"
|
||||
class="h-7 min-w-0 flex-1 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderRangeEnd')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
|
||||
@@ -679,33 +778,78 @@ function blurValueRule(id: string) {
|
||||
@compositionstart="startImeComposition(`value-end:${rule.id}`)"
|
||||
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'end')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'end', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="filterModeUsesList(rule.mode)" class="col-start-2 col-span-3 flex items-start gap-1.5">
|
||||
<textarea
|
||||
data-filter-value-editor
|
||||
:value="rule.rawValue"
|
||||
rows="2"
|
||||
class="min-h-12 min-w-0 flex-1 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValues')"
|
||||
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
|
||||
@keydown.ctrl.enter.prevent="emit('apply')"
|
||||
@keydown.meta.enter.prevent="emit('apply')"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
:multiple="true"
|
||||
:selected-keys="props.valueSuggestions.selectedKeys"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@toggle="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all="emit('toggleAllValueSuggestions')"
|
||||
@apply="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="filterModeNeedsValue(rule.mode)" class="col-start-4 flex min-w-0 gap-1.5">
|
||||
<Input
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-7 min-w-0 flex-1 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@focus="focusValueRule(rule.id, index, rule.mode)"
|
||||
@blur="blurValueRule(rule.id)"
|
||||
@compositionend="endImeComposition(`value:${rule.id}`)"
|
||||
@compositionstart="startImeComposition(`value:${rule.id}`)"
|
||||
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
v-else-if="filterModeUsesList(rule.mode)"
|
||||
data-filter-value-editor
|
||||
:value="rule.rawValue"
|
||||
rows="2"
|
||||
class="col-start-2 col-span-3 min-h-12 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValues')"
|
||||
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
|
||||
@keydown.ctrl.enter.prevent="emit('apply')"
|
||||
@keydown.meta.enter.prevent="emit('apply')"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="filterModeNeedsValue(rule.mode)"
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-7 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@focus="focusValueRule(rule.id, index, rule.mode)"
|
||||
@blur="blurValueRule(rule.id)"
|
||||
@compositionend="endImeComposition(`value:${rule.id}`)"
|
||||
@compositionstart="startImeComposition(`value:${rule.id}`)"
|
||||
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
|
||||
/>
|
||||
<div v-else class="flex h-7 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
|
||||
<div v-if="props.layout !== 'text' && shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-start-2 col-span-3 -mt-0.5' : 'col-start-4 row-start-2 -mt-0.5'">
|
||||
{{ t("grid.filterBuilderValueShortcutHint") }}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
|
||||
const props = defineProps<{
|
||||
sqlPreview: string;
|
||||
@@ -16,6 +18,7 @@ const props = defineProps<{
|
||||
columnSearch: string;
|
||||
applyOnlyBusy?: boolean;
|
||||
disabled?: boolean;
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -30,6 +33,13 @@ const emit = defineEmits<{
|
||||
removeRule: [id: string];
|
||||
moveRule: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -64,6 +74,7 @@ watch(
|
||||
:mode-options="modeOptions"
|
||||
:column-search="columnSearch"
|
||||
:disabled="disabled"
|
||||
:value-suggestions="valueSuggestions"
|
||||
layout="panel"
|
||||
:show-header="false"
|
||||
:show-footer="false"
|
||||
@@ -76,6 +87,13 @@ watch(
|
||||
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
|
||||
@update-rule="(id, patch) => emit('updateRule', id, patch)"
|
||||
@update:column-search="emit('update:columnSearch', $event)"
|
||||
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
|
||||
@close-value-suggestions="emit('closeValueSuggestions')"
|
||||
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select-value-suggestion="emit('selectValueSuggestion', $event)"
|
||||
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
|
||||
@apply-value-suggestions="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { DataGridConditionColumnOption } from "@/composables/useDataGridCon
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import type { DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import type { DataGridFilterEditorView } from "@/stores/settingsStore";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
|
||||
@@ -42,6 +44,7 @@ const props = defineProps<{
|
||||
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
|
||||
columnSearch: string;
|
||||
applyOnlyBusy?: boolean;
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
applyWhere: (value?: string) => void | boolean | Promise<void | boolean>;
|
||||
applyOrderBy: (value?: string) => void | boolean | Promise<void | boolean>;
|
||||
clearOrderBy: () => void | Promise<void>;
|
||||
@@ -62,6 +65,13 @@ const emit = defineEmits<{
|
||||
moveRule: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
clearLocalFilter: [columnIndex?: number];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -151,6 +161,11 @@ async function handleFilterButtonClick() {
|
||||
await openPendingFirstEmptyRuleColumnSearch();
|
||||
}
|
||||
|
||||
function updateQuickFilterBuilderOpen(open: boolean) {
|
||||
emit("update:filterBuilderOpen", open);
|
||||
if (!open) emit("closeValueSuggestions");
|
||||
}
|
||||
|
||||
watch([() => props.filterBuilderOpen, () => props.rules.map((rule) => `${rule.id}:${rule.columnName}:${rule.disabled ? "1" : "0"}`).join("\u0000"), filterBuilderRef], () => void openPendingFirstEmptyRuleColumnSearch(), { flush: "post" });
|
||||
|
||||
onUnmounted(onResizeEnd);
|
||||
@@ -160,7 +175,7 @@ onUnmounted(onResizeEnd);
|
||||
<div ref="containerRef" class="flex flex-1 min-w-0">
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': leadingBorder }" :style="wherePaneStyle">
|
||||
<template v-if="filterEditorView === 'quick'">
|
||||
<Popover :open="filterBuilderOpen" @update:open="emit('update:filterBuilderOpen', $event)">
|
||||
<Popover :open="filterBuilderOpen" @update:open="updateQuickFilterBuilderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,6 +227,7 @@ onUnmounted(onResizeEnd);
|
||||
:column-search="columnSearch"
|
||||
:disabled="!canUseWhereSearch"
|
||||
:show-header="false"
|
||||
:value-suggestions="valueSuggestions"
|
||||
@add="emit('addRule')"
|
||||
@apply-only="emit('applyOnly', $event)"
|
||||
@apply="emit('applyFilters')"
|
||||
@@ -221,6 +237,13 @@ onUnmounted(onResizeEnd);
|
||||
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
|
||||
@update-rule="updateRule"
|
||||
@update:column-search="emit('update:columnSearch', $event)"
|
||||
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
|
||||
@close-value-suggestions="emit('closeValueSuggestions')"
|
||||
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select-value-suggestion="emit('selectValueSuggestion', $event)"
|
||||
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
|
||||
@apply-value-suggestions="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN } from "@/lib/dataGrid/dataGridTextFilterPanel";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -19,6 +21,7 @@ const props = defineProps<{
|
||||
columnSearch: string;
|
||||
applyOnlyBusy?: boolean;
|
||||
disabled?: boolean;
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,6 +37,13 @@ const emit = defineEmits<{
|
||||
removeRule: [id: string];
|
||||
moveRule: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -108,6 +118,7 @@ function addRuleFromBlankArea(event: KeyboardEvent) {
|
||||
|
||||
onMounted(() => emit("ensureRule"));
|
||||
onBeforeUnmount(() => {
|
||||
emit("closeValueSuggestions");
|
||||
stopResizeListeners();
|
||||
restoreDocumentInteraction();
|
||||
});
|
||||
@@ -153,6 +164,7 @@ watch(
|
||||
:mode-options="modeOptions"
|
||||
:column-search="columnSearch"
|
||||
:disabled="disabled"
|
||||
:value-suggestions="valueSuggestions"
|
||||
layout="text"
|
||||
:show-header="false"
|
||||
:show-footer="false"
|
||||
@@ -165,6 +177,13 @@ watch(
|
||||
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
|
||||
@update-rule="(id, patch) => emit('updateRule', id, patch)"
|
||||
@update:column-search="emit('update:columnSearch', $event)"
|
||||
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
|
||||
@close-value-suggestions="emit('closeValueSuggestions')"
|
||||
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select-value-suggestion="emit('selectValueSuggestion', $event)"
|
||||
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
|
||||
@apply-value-suggestions="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,16 +19,16 @@ describe("DataGridBusyOverlay", () => {
|
||||
it("shows the elapsed seconds without a stop action by default", () => {
|
||||
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 12_340 });
|
||||
|
||||
expect(hostText(mounted.root)).toContain("12.34s");
|
||||
expect(hostText(mounted.root)).toContain("12.3s");
|
||||
expect(findCancelButton(mounted.root)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("formats long waits and guards non-finite elapsed values", async () => {
|
||||
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 312_450 });
|
||||
expect(hostText(mounted.root)).toContain("312.45s");
|
||||
expect(hostText(mounted.root)).toContain("312.4s");
|
||||
|
||||
await mounted.setProps({ elapsedMs: Number.NaN });
|
||||
expect(hostText(mounted.root)).toContain("0.00s");
|
||||
expect(hostText(mounted.root)).toContain("0.0s");
|
||||
});
|
||||
|
||||
it("stops the running load from the elapsed pill", () => {
|
||||
@@ -70,7 +70,7 @@ describe("DataGridBusyOverlay", () => {
|
||||
|
||||
expect(hostText(mounted.root)).toContain("grid.pageJumpLoading");
|
||||
expect(hostText(mounted.root)).toContain("grid.pageJumpProgress");
|
||||
expect(hostText(mounted.root)).toContain("45.00s");
|
||||
expect(hostText(mounted.root)).toContain("45.0s");
|
||||
const progressbar = findOne(mounted.root, (node) => node.props.role === "progressbar");
|
||||
expect(progressbar.props["aria-valuenow"]).toBe(3);
|
||||
expect(progressbar.props["aria-valuemax"]).toBe(12);
|
||||
|
||||
@@ -103,6 +103,7 @@ import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.v
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
|
||||
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
|
||||
import DataGridDistinctValuePopover from "@/components/grid/DataGridDistinctValuePopover.vue";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
|
||||
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
|
||||
@@ -113,6 +114,14 @@ import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
|
||||
const cellDetailPanelSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailPanel.vue", "utf8");
|
||||
const cellDetailDialogSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailDialog.vue", "utf8");
|
||||
const binaryTextPreviewSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailTextPreview.vue", "utf8");
|
||||
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
|
||||
describe("DataGrid opaque aggregate-state safety", () => {
|
||||
it("blocks cell edits and keyless row deletion through the centralized gates", () => {
|
||||
expect(dataGridSource).toMatch(/function canEditCellItem[\s\S]*isOpaqueAggregateStateColumnType\(allColumnTypes\.value\[columnIndex\]\)/);
|
||||
expect(dataGridSource).toMatch(/function canDeleteRowItem[\s\S]*canUseKeylessRowPredicate[\s\S]*hasUnsafeOpaqueAggregateStatePredicate\(allColumnTypes\.value, item\.data\)/);
|
||||
});
|
||||
});
|
||||
|
||||
function detail(patch: Partial<DataGridCellDetail> = {}): DataGridCellDetail {
|
||||
return {
|
||||
@@ -1133,6 +1142,50 @@ describe("DataGridQueryControls", () => {
|
||||
expect(popoverContent.props["collision-padding"]).toBe(8);
|
||||
});
|
||||
|
||||
it("forwards database value suggestions in the quick filter popover", () => {
|
||||
const openValueSuggestions = vi.fn();
|
||||
const closeValueSuggestions = vi.fn();
|
||||
const mounted = mountComponent(DataGridQueryControls, {
|
||||
whereInput: "",
|
||||
orderByInput: "",
|
||||
columns: ["status"],
|
||||
conditionColumns: ["status"],
|
||||
historyScope: {},
|
||||
canUseWhereSearch: true,
|
||||
compact: false,
|
||||
leadingBorder: false,
|
||||
filterBuilderOpen: true,
|
||||
filterEditorView: "quick",
|
||||
filterButtonActive: false,
|
||||
filterButtonCount: 0,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterCount: 0,
|
||||
localFilterSummaries: [],
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
valueSuggestions: { search: "", options: [], loading: false, error: "", limited: false, limit: 1000, selectedKeys: new Set<string>() },
|
||||
applyWhere: vi.fn(),
|
||||
applyOrderBy: vi.fn(),
|
||||
clearOrderBy: vi.fn(),
|
||||
onOpenValueSuggestions: openValueSuggestions,
|
||||
onCloseValueSuggestions: closeValueSuggestions,
|
||||
});
|
||||
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
const suggestionPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === false);
|
||||
const openHandler = suggestionPopover.props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
|
||||
|
||||
expect(openValueSuggestions).toHaveBeenCalledWith("r1", "value");
|
||||
|
||||
const quickFilterPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === true);
|
||||
const closeHandler = quickFilterPopover.props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(closeHandler) ? closeHandler : [closeHandler]) callback(false);
|
||||
expect(closeValueSuggestions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps filter actions available in the popover", () => {
|
||||
const addRule = vi.fn();
|
||||
const clearFilters = vi.fn();
|
||||
@@ -1244,7 +1297,121 @@ describe("DataGridQueryControls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridDistinctValuePopover", () => {
|
||||
it("renders counts and distinct loading, error, and limit states", async () => {
|
||||
const mounted = mountComponent(DataGridDistinctValuePopover, {
|
||||
open: true,
|
||||
search: "",
|
||||
options: [{ key: "str:open", label: "open", count: 12, value: "open" }],
|
||||
loading: true,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
});
|
||||
expect(hostText(mounted.root)).toContain("grid.loadingValues");
|
||||
expect(hostText(mounted.root)).toContain("open");
|
||||
expect(hostText(mounted.root)).toContain("12");
|
||||
|
||||
await mounted.setProps({ loading: false, error: "query failed", options: [] });
|
||||
expect(hostText(mounted.root)).toContain("query failed");
|
||||
expect(hostText(mounted.root)).not.toContain("grid.noSearchResults");
|
||||
|
||||
await mounted.setProps({ error: "", limited: true });
|
||||
expect(hostText(mounted.root)).toContain("grid.serverValuesLimited");
|
||||
});
|
||||
|
||||
it("selects all displayed candidates and matches the database-value filter checkbox state", async () => {
|
||||
const toggleAll = vi.fn();
|
||||
const options = [
|
||||
{ key: "str:open", label: "open", count: 12, value: "open" },
|
||||
{ key: "str:closed", label: "closed", count: 4, value: "closed" },
|
||||
];
|
||||
const mounted = mountComponent(DataGridDistinctValuePopover, {
|
||||
open: true,
|
||||
search: "",
|
||||
options,
|
||||
loading: false,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
multiple: true,
|
||||
selectedKeys: new Set<string>(),
|
||||
onToggleAll: toggleAll,
|
||||
});
|
||||
const findSelectAll = () => findOne(mounted.root, (node) => node.props["data-distinct-value-select-all"] === "");
|
||||
|
||||
expect(findSelectAll().props["aria-checked"]).toBe(false);
|
||||
dispatch(findSelectAll(), "click");
|
||||
expect(toggleAll).toHaveBeenCalledOnce();
|
||||
|
||||
await mounted.setProps({ selectedKeys: new Set(["str:open"]) });
|
||||
expect(findSelectAll().props["aria-checked"]).toBe(false);
|
||||
|
||||
await mounted.setProps({ selectedKeys: new Set(options.map((option) => option.key)) });
|
||||
expect(findSelectAll().props["aria-checked"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridFilterWorkbench", () => {
|
||||
it("shows database value suggestions only for enabled value rules in the conditions panel", async () => {
|
||||
const openSuggestions = vi.fn();
|
||||
const valueSuggestions = {
|
||||
ruleId: undefined,
|
||||
target: undefined,
|
||||
search: "",
|
||||
options: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
selectedKeys: new Set<string>(),
|
||||
};
|
||||
const mounted = mountComponent(DataGridFilterWorkbench, {
|
||||
sqlPreview: "",
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["status"],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [
|
||||
{ value: "equals", labelKey: "equals" },
|
||||
{ value: "is-null", labelKey: "is-null" },
|
||||
],
|
||||
columnSearch: "",
|
||||
valueSuggestions,
|
||||
onOpenValueSuggestions: openSuggestions,
|
||||
});
|
||||
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
const openHandler = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover").props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
|
||||
expect(openSuggestions).toHaveBeenCalledWith("r1", "value");
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND", disabled: true }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(0);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "is-null", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(0);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "between", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(2);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "in", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exposes value suggestions in the reusable popover builder when provided", () => {
|
||||
const mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["status"],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
layout: "popover",
|
||||
valueSuggestions: { search: "", options: [], loading: false, error: "", limited: false, limit: 1000, selectedKeys: new Set<string>() },
|
||||
});
|
||||
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("scrolls to the newest rule when a condition is added", async () => {
|
||||
const requestAnimationFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
@@ -1370,6 +1537,57 @@ describe("DataGridFilterWorkbench", () => {
|
||||
});
|
||||
|
||||
describe("DataGridTextFilterWorkbench", () => {
|
||||
it("shows and forwards database value suggestions in the text filter panel", async () => {
|
||||
const openValueSuggestions = vi.fn();
|
||||
const closeValueSuggestions = vi.fn();
|
||||
const valueSuggestions = {
|
||||
ruleId: undefined,
|
||||
target: undefined,
|
||||
search: "",
|
||||
options: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
selectedKeys: new Set<string>(),
|
||||
};
|
||||
const mounted = mountComponent(DataGridTextFilterWorkbench, {
|
||||
height: 168,
|
||||
sqlPreview: "",
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["status"],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [
|
||||
{ value: "equals", labelKey: "equals" },
|
||||
{ value: "between", labelKey: "between" },
|
||||
{ value: "in", labelKey: "in" },
|
||||
],
|
||||
columnSearch: "",
|
||||
valueSuggestions,
|
||||
onOpenValueSuggestions: openValueSuggestions,
|
||||
onCloseValueSuggestions: closeValueSuggestions,
|
||||
});
|
||||
|
||||
const suggestionButtons = () => findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter");
|
||||
expect(suggestionButtons()).toHaveLength(1);
|
||||
expect(String(suggestionButtons()[0].props.class)).toContain("h-6");
|
||||
|
||||
const suggestionPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === false);
|
||||
const openHandler = suggestionPopover.props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
|
||||
expect(openValueSuggestions).toHaveBeenCalledWith("r1", "value");
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "between", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(suggestionButtons()).toHaveLength(2);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "in", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(suggestionButtons()).toHaveLength(1);
|
||||
expect(findAll(mounted.root, (node) => node.props["data-distinct-value-select-all"] === "")).toHaveLength(1);
|
||||
|
||||
mounted.unmount();
|
||||
expect(closeValueSuggestions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("exposes a persisted keyboard-resizable text filter panel", async () => {
|
||||
const ensureRule = vi.fn();
|
||||
const updateHeight = vi.fn();
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, markRaw, nextTick, ref, type App, type PropType } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { sortDataGridRowIndexes, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||||
|
||||
vi.mock("vue-virtual-scroller", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
RecycleScroller: defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<unknown[]>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h("div", attrs, [slots.before?.(), props.items.map((item, index) => slots.default?.({ item, index }))]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
|
||||
const { ref } = await import("vue");
|
||||
return {
|
||||
...actual,
|
||||
useDataGridColumnResize: () => ({
|
||||
initColumnWidths: vi.fn(),
|
||||
onResizeStart: vi.fn(),
|
||||
autoFitColumn: vi.fn(),
|
||||
renderedColumnWidths: ref([120, 120]),
|
||||
totalWidth: ref(240),
|
||||
columnVars: ref({ "--total-w": "240px" }),
|
||||
getIsResizing: () => false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import DataGrid from "../DataGrid.vue";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function menuAction(label: string): HTMLButtonElement {
|
||||
const button = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find((candidate) => candidate.textContent?.trim() === label);
|
||||
if (!button) throw new Error(`Sort menu action not found: ${label}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
function transposeField(host: HTMLElement, columnIndex: number): HTMLElement {
|
||||
const field = host.querySelector<HTMLElement>(`[data-grid-transpose-column-index="${columnIndex}"]`);
|
||||
if (!field) throw new Error(`Transpose field not found: ${columnIndex}`);
|
||||
return field.closest<HTMLElement>(".data-grid-transpose-row")!;
|
||||
}
|
||||
|
||||
function transposeValues(host: HTMLElement, columnIndex: number): string[] {
|
||||
return [...transposeField(host, columnIndex).querySelectorAll<HTMLElement>("[data-grid-transpose-cell]")].map((cell) => cell.textContent?.trim() ?? "");
|
||||
}
|
||||
|
||||
function transposeRecordHeader(host: HTMLElement, recordIndex: number): HTMLElement {
|
||||
const header = host.querySelector<HTMLElement>(`[data-grid-transpose-record-header][data-grid-transpose-record-index="${recordIndex}"]`);
|
||||
if (!header) throw new Error(`Transpose record header not found: ${recordIndex}`);
|
||||
return header;
|
||||
}
|
||||
|
||||
function mountGrid() {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const settingsStore = useSettingsStore();
|
||||
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom", dataGridMultiRowTranspose: true });
|
||||
|
||||
const originalRows: QueryResult["rows"] = [
|
||||
["b", "10"],
|
||||
["a", null],
|
||||
["c", "2"],
|
||||
];
|
||||
const result = ref<QueryResult>(
|
||||
markRaw({
|
||||
columns: ["name", "score"],
|
||||
column_types: ["varchar", "bigint"],
|
||||
rows: originalRows,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 0,
|
||||
}),
|
||||
);
|
||||
const sortColumn = ref<string>();
|
||||
const sortColumnIndex = ref<number>();
|
||||
const sortDirection = ref<DataGridSortDirection>();
|
||||
const sortMode = ref<DataGridSortMode>();
|
||||
const onSort = vi.fn((column: string, columnIndex: number, direction: DataGridSortDirection | null, _whereInput?: string, mode: DataGridSortMode = "database") => {
|
||||
const indexes = direction ? sortDataGridRowIndexes(originalRows, columnIndex, direction, result.value.column_types?.[columnIndex]) : originalRows.map((_, index) => index);
|
||||
result.value = markRaw({ ...result.value, rows: indexes.map((index) => originalRows[index]!) });
|
||||
sortColumn.value = direction ? column : undefined;
|
||||
sortColumnIndex.value = direction ? columnIndex : undefined;
|
||||
sortDirection.value = direction ?? undefined;
|
||||
sortMode.value = direction ? mode : undefined;
|
||||
});
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const Root = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
TooltipProvider,
|
||||
{ delayDuration: 0 },
|
||||
{
|
||||
default: () =>
|
||||
h(DataGrid, {
|
||||
result: result.value,
|
||||
databaseType: "mysql",
|
||||
context: "table-data",
|
||||
sortColumn: sortColumn.value,
|
||||
sortColumnIndex: sortColumnIndex.value,
|
||||
sortDirection: sortDirection.value,
|
||||
sortMode: sortMode.value,
|
||||
tableMeta: {
|
||||
tableName: "scores",
|
||||
columns: [
|
||||
{ name: "name", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "score", data_type: "bigint", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
primaryKeys: ["name"],
|
||||
},
|
||||
onSort,
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const app = createApp(Root);
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
return { host, onSort };
|
||||
}
|
||||
|
||||
async function openTranspose(host: HTMLElement) {
|
||||
const firstRowNumber = host.querySelector<HTMLElement>(".data-grid-row-number");
|
||||
if (!firstRowNumber) throw new Error("Row number not found");
|
||||
firstRowNumber.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
|
||||
firstRowNumber.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, button: 0 }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function selectTransposeSort(host: HTMLElement, columnIndex: number, labelKey: string) {
|
||||
const trigger = transposeField(host, columnIndex).querySelector<HTMLButtonElement>("[data-grid-transpose-sort]");
|
||||
if (!trigger) throw new Error(`Transpose sort trigger not found: ${columnIndex}`);
|
||||
trigger.click();
|
||||
await settle();
|
||||
menuAction(i18n.global.t(labelKey)).click();
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("DataGrid transpose sorting", () => {
|
||||
it("uses the shared local/database sort actions and keeps the active record and row selection", async () => {
|
||||
const { host, onSort } = mountGrid();
|
||||
await settle();
|
||||
await openTranspose(host);
|
||||
|
||||
expect(transposeValues(host, 1)).toEqual(["10", "NULL", "2"]);
|
||||
const nextRecordButton = host.querySelector(".lucide-chevron-right")?.closest<HTMLButtonElement>("button");
|
||||
if (!nextRecordButton) throw new Error("Next transpose record button not found");
|
||||
nextRecordButton.click();
|
||||
await settle();
|
||||
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
|
||||
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-active")).toBe(true);
|
||||
|
||||
await selectTransposeSort(host, 1, "grid.sortCurrentPageAscending");
|
||||
expect(onSort).toHaveBeenLastCalledWith("score", 1, "asc", undefined, "local");
|
||||
expect(transposeValues(host, 1)).toEqual(["2", "10", "NULL"]);
|
||||
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-selected")).toBe(true);
|
||||
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
|
||||
|
||||
await selectTransposeSort(host, 1, "grid.sortDatabaseDescending");
|
||||
expect(onSort).toHaveBeenLastCalledWith("score", 1, "desc", undefined, "database");
|
||||
expect(transposeValues(host, 1)).toEqual(["10", "2", "NULL"]);
|
||||
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
|
||||
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
|
||||
|
||||
await selectTransposeSort(host, 1, "grid.clearSort");
|
||||
expect(onSort).toHaveBeenLastCalledWith("score", 1, null, undefined, "database");
|
||||
expect(transposeValues(host, 1)).toEqual(["10", "NULL", "2"]);
|
||||
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
|
||||
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-active")).toBe(true);
|
||||
expect(host.querySelector(".transpose-grid-scroller")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import { detectKvValueFormat, validateKvValue, type KvValueFormat } from "@/lib/kv/kvValueFormat";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { saveTextFile } from "@/lib/export/saveTextFile";
|
||||
|
||||
interface KvKeyBrowserLabels {
|
||||
prefixPlaceholder: string;
|
||||
@@ -933,7 +934,7 @@ async function refreshLazyParent(parentPath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedKey(input: string | KvKeyRoute) {
|
||||
async function loadSelectedKey(input: string | KvKeyRoute): Promise<boolean> {
|
||||
const route = routeFromKey(input);
|
||||
const key = route.key;
|
||||
const keyIdentity = routeIdentity(route);
|
||||
@@ -950,7 +951,7 @@ async function loadSelectedKey(input: string | KvKeyRoute) {
|
||||
detailError.value = "";
|
||||
try {
|
||||
const result = await props.api.get(connectionId, key, route.keyBytes ? { keyBytes: route.keyBytes } : undefined);
|
||||
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return;
|
||||
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return false;
|
||||
const lazyNode = lazyTreeState.nodeByKey.get(key);
|
||||
if (lazyNode) {
|
||||
lazyNode.hasValue = result.found;
|
||||
@@ -958,16 +959,18 @@ async function loadSelectedKey(input: string | KvKeyRoute) {
|
||||
}
|
||||
if (!result.found) {
|
||||
removeExpiredSelectedKey(key);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
selectedValue.value = result;
|
||||
selectedKeyIdentity.value = result.keyIdentity ?? selectedKeyIdentity.value;
|
||||
selectedRouteKeyBytes.value = result.keyBytes ?? selectedRouteKeyBytes.value;
|
||||
startMetadataRefresh(key);
|
||||
startKeyListRefresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return;
|
||||
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return false;
|
||||
detailError.value = formatError(error);
|
||||
return false;
|
||||
} finally {
|
||||
if (requestId === detailRequestId && connectionId === props.connectionId) detailLoading.value = false;
|
||||
}
|
||||
@@ -1487,54 +1490,47 @@ function watchSelectedKey() {
|
||||
});
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string, type = "application/json") {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportSelectedKey() {
|
||||
if (!selectedKey.value || !selectedValue.value?.found) return;
|
||||
downloadText(
|
||||
`${selectedKey.value.split("/").filter(Boolean).pop() || props.exportFallbackName}${props.exportFileExtension}`,
|
||||
JSON.stringify(
|
||||
{
|
||||
format: props.exportFormat,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
prefix: selectedKey.value,
|
||||
scopeKind: "key",
|
||||
entries: [
|
||||
{
|
||||
key: selectedKeyBytes.value ?? { encoding: "utf8", data: selectedKey.value },
|
||||
value: selectedValue.value.value,
|
||||
metadata: selectedValue.value.metadata,
|
||||
formatHint: detectKvValueFormat(selectedTextValue.value, selectedValueIsBase64.value ? "base64" : "utf8"),
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
async function exportSelectedKey(): Promise<boolean> {
|
||||
if (!selectedKey.value || !selectedValue.value?.found) return false;
|
||||
const filename = `${selectedKey.value.split("/").filter(Boolean).pop() || props.exportFallbackName}${props.exportFileExtension}`;
|
||||
const content = JSON.stringify(
|
||||
{
|
||||
format: props.exportFormat,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
prefix: selectedKey.value,
|
||||
scopeKind: "key",
|
||||
entries: [
|
||||
{
|
||||
key: selectedKeyBytes.value ?? { encoding: "utf8", data: selectedKey.value },
|
||||
value: selectedValue.value.value,
|
||||
metadata: selectedValue.value.metadata,
|
||||
formatHint: detectKvValueFormat(selectedTextValue.value, selectedValueIsBase64.value ? "base64" : "utf8"),
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return saveTextFile(content, filename, "DBX KV Bundle", "json", { operation: "kv-key-export" });
|
||||
}
|
||||
|
||||
async function exportNode(node: BrowserTreeNode) {
|
||||
const request: KvExportScopeRequest = {
|
||||
path: nodePath(node),
|
||||
kind: nodeIsExpandable(node) ? "prefix" : "key",
|
||||
keyBytes: node.kind === "lazy" ? null : node.keyBytes,
|
||||
};
|
||||
if (props.api.exportScope) {
|
||||
await props.api.exportScope(props.connectionId, request);
|
||||
return;
|
||||
try {
|
||||
const request: KvExportScopeRequest = {
|
||||
path: nodePath(node),
|
||||
kind: nodeIsExpandable(node) ? "prefix" : "key",
|
||||
keyBytes: node.kind === "lazy" ? null : node.keyBytes,
|
||||
};
|
||||
if (props.api.exportScope) {
|
||||
await props.api.exportScope(props.connectionId, request);
|
||||
return;
|
||||
}
|
||||
if (!nodeHasValue(node)) return;
|
||||
if (await loadSelectedKey(routeFromNode(node))) await exportSelectedKey();
|
||||
} catch (error) {
|
||||
toast(formatError(error), 4000);
|
||||
}
|
||||
if (!nodeHasValue(node)) return;
|
||||
await loadSelectedKey(routeFromNode(node));
|
||||
exportSelectedKey();
|
||||
}
|
||||
|
||||
async function openCloneDialog() {
|
||||
|
||||
@@ -235,6 +235,7 @@ watch(
|
||||
/>
|
||||
<DataTransferDialog
|
||||
v-model:open="dialogs.showTransferDialog.value"
|
||||
:task-id="dialogs.transferTaskId.value"
|
||||
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.transferPrefillDatabase.value"
|
||||
:prefill-catalog="dialogs.transferPrefillCatalog.value"
|
||||
@@ -308,6 +309,7 @@ watch(
|
||||
<DataGenerateDialog
|
||||
v-if="dialogs.showTableDataGenerateDialog.value"
|
||||
v-model:open="dialogs.showTableDataGenerateDialog.value"
|
||||
:session-id="dialogs.tableDataGenerateSessionId.value"
|
||||
:prefill-connection-id="dialogs.tableDataGeneratePrefillConnectionId.value"
|
||||
:prefill-database="dialogs.tableDataGeneratePrefillDatabase.value"
|
||||
:prefill-schema="dialogs.tableDataGeneratePrefillSchema.value"
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { computed, ref, onMounted, onBeforeUnmount, h, nextTick, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree, Pin, PinOff } from "@lucide/vue";
|
||||
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree, Pin, PinOff, CalendarClock, Waypoints } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
|
||||
import WindowControls from "@/components/layout/WindowControls.vue";
|
||||
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
|
||||
import ToolbarUpdateIcon from "@/components/layout/ToolbarUpdateIcon.vue";
|
||||
@@ -73,6 +73,8 @@ const emit = defineEmits<{
|
||||
"open-sql-file": [];
|
||||
"open-schema-diff": [];
|
||||
"open-data-compare": [];
|
||||
"open-backups": [];
|
||||
"open-mcp-settings": [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -429,11 +431,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
// ──────────── Left-side "More" items ────────────
|
||||
|
||||
const moreItems = computed(() => {
|
||||
const items: Array<{ value: string; label: string; icon: any; action: () => void; disabled: boolean }> = [];
|
||||
type ToolbarMenuItem = LightDropdownItem & { action: () => void };
|
||||
|
||||
// Hidden left-side items go into "More"
|
||||
if (!toolbarItems.value.dataTransfer) {
|
||||
function buildToolbarMenuItems(includeVisiblePrimaryItems: boolean): ToolbarMenuItem[] {
|
||||
const items: ToolbarMenuItem[] = [];
|
||||
|
||||
if (includeVisiblePrimaryItems || !toolbarItems.value.dataTransfer) {
|
||||
items.push({
|
||||
value: "transfer",
|
||||
label: t("transfer.dataTransfer"),
|
||||
@@ -442,20 +445,18 @@ const moreItems = computed(() => {
|
||||
disabled: !props.hasConnections,
|
||||
});
|
||||
}
|
||||
if (!toolbarItems.value.driverManager) {
|
||||
if (includeVisiblePrimaryItems || !toolbarItems.value.driverManager) {
|
||||
items.push({
|
||||
value: "driver-store",
|
||||
label: t("toolbar.driverManager"),
|
||||
label: props.agentDriverUpdateCount > 0 ? `${t("toolbar.driverManager")} (${props.agentDriverUpdateCount})` : t("toolbar.driverManager"),
|
||||
icon: Package,
|
||||
action: () => emit("open-driver-store"),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
if (!toolbarItems.value.pluginCenter) {
|
||||
if (includeVisiblePrimaryItems || !toolbarItems.value.pluginCenter) {
|
||||
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
|
||||
}
|
||||
|
||||
// "More" menu items (individually toggleable)
|
||||
if (toolbarItems.value.sqlFile) {
|
||||
items.push({
|
||||
value: "sql-file",
|
||||
@@ -483,52 +484,39 @@ const moreItems = computed(() => {
|
||||
disabled: !props.hasConnections,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
value: "database-backups",
|
||||
label: t("databaseBackup.title"),
|
||||
icon: CalendarClock,
|
||||
action: () => emit("open-backups"),
|
||||
disabled: false,
|
||||
});
|
||||
items.push({
|
||||
value: "mcp-settings",
|
||||
label: t("settings.openMcpSettings"),
|
||||
icon: Waypoints,
|
||||
action: () => emit("open-mcp-settings"),
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// Append overflowed right-side items at the end
|
||||
for (const ri of overflowRightMenuItems.value) {
|
||||
for (const item of overflowRightMenuItems.value) {
|
||||
items.push({
|
||||
value: `right-${ri.value}`,
|
||||
label: ri.label,
|
||||
icon: ri.icon,
|
||||
action: ri.action,
|
||||
disabled: ri.disabled,
|
||||
value: `right-${item.value}`,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
action: item.action,
|
||||
disabled: item.disabled,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
}
|
||||
|
||||
const moreItems = computed(() => buildToolbarMenuItems(false));
|
||||
|
||||
const showMoreDropdown = computed(() => moreItems.value.length > 0);
|
||||
|
||||
const collapsedItems = computed(() => {
|
||||
const items: Array<{ value: string; label: string; icon: any; action: () => void; disabled: boolean }> = [];
|
||||
if (toolbarItems.value.dataTransfer) {
|
||||
items.push({
|
||||
value: "transfer",
|
||||
label: t("transfer.dataTransfer"),
|
||||
icon: ArrowLeftRight,
|
||||
action: () => emit("open-transfer"),
|
||||
disabled: !props.hasConnections,
|
||||
});
|
||||
}
|
||||
if (toolbarItems.value.driverManager) {
|
||||
items.push({
|
||||
value: "driver-store",
|
||||
label: props.agentDriverUpdateCount > 0 ? `${t("toolbar.driverManager")} (${props.agentDriverUpdateCount})` : t("toolbar.driverManager"),
|
||||
icon: Package,
|
||||
action: () => emit("open-driver-store"),
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
if (toolbarItems.value.pluginCenter) {
|
||||
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
|
||||
}
|
||||
// Always include moreItems (may contain hidden left-side items + overflowed right items)
|
||||
if (moreItems.value.length > 0) {
|
||||
items.push(...moreItems.value);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
const collapsedItems = computed(() => buildToolbarMenuItems(true));
|
||||
|
||||
function runMoreItem(value: string) {
|
||||
const item = moreItems.value.find((i) => i.value === value);
|
||||
|
||||
@@ -96,6 +96,7 @@ function preloadDataGridComponent() {
|
||||
const QueryEditor = defineAsyncComponent({ loader: () => import("@/components/editor/QueryEditor.vue"), loadingComponent: QueryLoadingState, delay: 0 });
|
||||
const DataGrid = defineAsyncComponent(loadDataGridComponent);
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const RedisQueryConsoleOutput = defineAsyncComponent(() => import("@/components/redis/RedisQueryConsoleOutput.vue"));
|
||||
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
|
||||
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
|
||||
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
|
||||
@@ -176,6 +177,7 @@ import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
import { queryResultExportBaseName } from "@/lib/export/saveTextFile";
|
||||
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { buildMongoQueryResultOperations, formatMongoQueryResultOperationPreview } from "@/lib/mongo/mongoQueryResultEditing";
|
||||
import { buildInfluxDbV1DeleteStatements, canDeleteInfluxDbV1Row, resolveInfluxDbV1DeleteTarget } from "@/lib/influxdb/influxDbV1Delete";
|
||||
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||||
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
@@ -186,7 +188,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
|
||||
import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import type { QueryMessage, QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
|
||||
import type { QueryMessage, QueryTab, RedisResultViewMode, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
|
||||
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
@@ -613,6 +615,9 @@ const hasTabularResult = computed(() => {
|
||||
if (props.activeTab.result?.columns.length && props.activeTab.result.server_message !== true) return true;
|
||||
return visibleResultItems.value.length > 0;
|
||||
});
|
||||
const redisConsoleResults = computed(() => (props.activeTab.results?.length ? props.activeTab.results : props.activeTab.result ? [props.activeTab.result] : []));
|
||||
const canShowRedisConsoleOutput = computed(() => activeEffectiveDatabaseType.value === "redis" && (props.activeTab.isExecuting || redisConsoleResults.value.some((result) => result.execution_error === true || typeof result.redis_console_output === "string")));
|
||||
const redisResultViewMode = computed<RedisResultViewMode>(() => (activeEffectiveDatabaseType.value === "redis" ? (props.activeTab.uiState?.redisResultViewMode ?? "grid") : "grid"));
|
||||
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
|
||||
const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true);
|
||||
// A batch can attach server messages to more than one statement result (for
|
||||
@@ -626,7 +631,7 @@ const resultMessages = computed<QueryMessage[]>(() => {
|
||||
});
|
||||
const resultMessageCount = computed(() => resultMessages.value.length);
|
||||
const canShowMessagesOutput = computed(() => resultMessageCount.value > 0);
|
||||
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
|
||||
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || (redisResultViewMode.value === "console" && canShowRedisConsoleOutput.value) || !props.activeTab.result || !hasTabularResult.value);
|
||||
const standaloneResultToolbarCompact = computed(() => isDataGridToolbarCompact(standaloneResultToolbarWidth.value, standaloneResultToolbarViewportWidth.value));
|
||||
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
|
||||
|
||||
@@ -635,6 +640,11 @@ function updateStandaloneResultToolbarDimensions() {
|
||||
standaloneResultToolbarViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
|
||||
}
|
||||
|
||||
function setRedisResultViewMode(mode: RedisResultViewMode) {
|
||||
if (activeEffectiveDatabaseType.value !== "redis") return;
|
||||
queryStore.updateTabUiState(props.activeTab.id, { redisResultViewMode: mode });
|
||||
}
|
||||
|
||||
function observeStandaloneResultToolbar() {
|
||||
standaloneResultToolbarResizeObserver?.disconnect();
|
||||
standaloneResultToolbarResizeObserver = undefined;
|
||||
@@ -706,6 +716,45 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
|
||||
|
||||
return { save, preview, applySavedChanges, canInsert: false, canDelete: true, supportsInsert: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
|
||||
});
|
||||
const influxDbV1DeleteSaveHandler = computed<CustomSaveHandler | undefined>(() => {
|
||||
const tab = props.activeTab;
|
||||
const result = tab.result;
|
||||
const tableMeta = activeTableMeta.value;
|
||||
const connection = connectionStore.getConfig(tab.connectionId) ?? props.activeConnection;
|
||||
const database = activeDataTabExecutionDatabase.value;
|
||||
if (tab.mode !== "data" || tab.tableMetaPending || !result || !tableMeta || !tab.connectionId || !database.trim()) return undefined;
|
||||
|
||||
const target = resolveInfluxDbV1DeleteTarget({
|
||||
connection,
|
||||
measurement: tableMeta.tableName,
|
||||
tableColumns: tableMeta.columns,
|
||||
resultColumns: result.columns,
|
||||
});
|
||||
if (!target) return undefined;
|
||||
|
||||
const preview: NonNullable<CustomSaveHandler["preview"]> = async (changes) => buildInfluxDbV1DeleteStatements(target, changes);
|
||||
const save: CustomSaveHandler["save"] = async (changes) => {
|
||||
const statements = buildInfluxDbV1DeleteStatements(target, changes);
|
||||
if (statements.length === 0) throw new Error("InfluxDB deletion requires at least one guarded statement.");
|
||||
for (const statement of statements) {
|
||||
await api.executeQuery(tab.connectionId, database, statement);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
save,
|
||||
preview,
|
||||
canInsert: false,
|
||||
canUpdate: false,
|
||||
canDelete: true,
|
||||
canDeleteRow: (_sourceIndex, row) => canDeleteInfluxDbV1Row(target, row),
|
||||
confirmation: "influxdb-v1-delete",
|
||||
reloadOnFailure: true,
|
||||
supportsInsert: false,
|
||||
readonlyColumns: [...result.columns],
|
||||
targetLabel: target.measurement,
|
||||
};
|
||||
});
|
||||
const resultsPaneOpen = ref(false);
|
||||
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
|
||||
// In editor-only mode the results pane is never mounted in this splitpanes,
|
||||
@@ -1817,7 +1866,7 @@ defineExpose({
|
||||
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
|
||||
</template>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
|
||||
<Popover v-if="activeOutputView === 'result' && redisResultViewMode === 'grid' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
|
||||
<Wrench class="h-4 w-4" />
|
||||
@@ -2096,9 +2145,12 @@ defineExpose({
|
||||
:can-show-summary="hasExecutionSummary"
|
||||
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
|
||||
:can-show-messages="canShowMessagesOutput"
|
||||
:can-show-redis-console="canShowRedisConsoleOutput"
|
||||
:result-mode="redisResultViewMode"
|
||||
:message-count="resultMessageCount"
|
||||
:compact="standaloneResultToolbarCompact"
|
||||
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
|
||||
@select-result-mode="setRedisResultViewMode"
|
||||
/>
|
||||
<QueryResultToolbarActions
|
||||
class="ml-auto"
|
||||
@@ -2250,6 +2302,8 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RedisQueryConsoleOutput v-else-if="activeOutputView === 'result' && redisResultViewMode === 'console' && canShowRedisConsoleOutput" :result="activeTab.result" :results="activeTab.results" :loading="activeTab.isExecuting" />
|
||||
|
||||
<QueryMessagesView v-else-if="activeOutputView === 'messages'" class="flex-1 min-h-0" :messages="resultMessages" />
|
||||
|
||||
<template v-else>
|
||||
@@ -2337,9 +2391,12 @@ defineExpose({
|
||||
:can-show-summary="hasExecutionSummary"
|
||||
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
|
||||
:can-show-messages="canShowMessagesOutput"
|
||||
:can-show-redis-console="canShowRedisConsoleOutput"
|
||||
:result-mode="redisResultViewMode"
|
||||
:message-count="resultMessageCount"
|
||||
:compact="compact"
|
||||
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
|
||||
@select-result-mode="setRedisResultViewMode"
|
||||
/>
|
||||
<template v-if="activeElasticsearchRawBody">
|
||||
<div class="mx-1 h-4 w-px bg-border" />
|
||||
@@ -2731,7 +2788,8 @@ defineExpose({
|
||||
:initial-order-by-input="activeTab.orderByInput"
|
||||
:sql="activeTab.sql"
|
||||
:loading="activeTab.isExecuting"
|
||||
:editable="!activeTab.tableMetaPending && isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [], activeTableMeta?.tableType)"
|
||||
:editable="!activeTab.tableMetaPending && (isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [], activeTableMeta?.tableType) || !!influxDbV1DeleteSaveHandler)"
|
||||
:custom-save-handler="influxDbV1DeleteSaveHandler"
|
||||
context="table-data"
|
||||
:initial-where-input="activeTab.whereInput"
|
||||
:database-type="activeEffectiveDatabaseType"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { BarChart3, ListChecks, MessageSquareText } from "@lucide/vue";
|
||||
import { BarChart3, ListChecks, MessageSquareText, SquareTerminal } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import type { RedisResultViewMode } from "@/types/database";
|
||||
|
||||
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
type PrimaryResultView = Exclude<OutputView, "explain" | "profile">;
|
||||
@@ -15,32 +16,65 @@ const props = withDefaults(
|
||||
canShowSummary: boolean;
|
||||
canShowChart: boolean;
|
||||
canShowMessages: boolean;
|
||||
canShowRedisConsole?: boolean;
|
||||
resultMode?: RedisResultViewMode;
|
||||
messageCount?: number;
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{ compact: false, messageCount: 0 },
|
||||
{ compact: false, messageCount: 0, canShowRedisConsole: false, resultMode: "grid" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectView: [view: PrimaryResultView];
|
||||
selectResultMode: [mode: RedisResultViewMode];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const messagesTooltip = computed(() => (props.messageCount > 0 ? `${t("tabs.messages")} (${props.messageCount})` : t("tabs.messages")));
|
||||
const effectiveResultMode = computed<RedisResultViewMode>(() => (props.canShowRedisConsole ? props.resultMode : "grid"));
|
||||
|
||||
function selectView(view: PrimaryResultView) {
|
||||
if (props.activeView === view) return;
|
||||
emit("selectView", view);
|
||||
}
|
||||
|
||||
function selectResultMode(mode: RedisResultViewMode) {
|
||||
if (props.activeView === "result" && effectiveResultMode.value === mode) return;
|
||||
if (props.canShowRedisConsole && props.resultMode !== mode) emit("selectResultMode", mode);
|
||||
if (props.activeView !== "result") emit("selectView", "result");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-query-result-view-switcher class="flex shrink-0 items-center gap-1 px-1">
|
||||
<Button size="sm" :variant="activeView === 'result' ? 'secondary' : 'ghost'" class="h-5 shrink-0 px-2 text-xs leading-none" :disabled="!canShowResult" :aria-pressed="activeView === 'result'" @click="selectView('result')">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'result' && effectiveResultMode === 'grid' ? 'secondary' : 'ghost'"
|
||||
class="h-5 shrink-0 px-2 text-xs leading-none"
|
||||
:disabled="!canShowResult"
|
||||
:aria-pressed="activeView === 'result' && effectiveResultMode === 'grid'"
|
||||
@click="canShowRedisConsole ? selectResultMode('grid') : selectView('result')"
|
||||
>
|
||||
<span class="inline-flex h-4 items-center leading-none">{{ t("tabs.tableData") }}</span>
|
||||
</Button>
|
||||
|
||||
<LightTooltip v-if="canShowRedisConsole" :text="t('redis.commandLine')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'result' && effectiveResultMode === 'console' ? 'secondary' : 'ghost'"
|
||||
class="h-5 shrink-0 text-xs leading-none"
|
||||
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
|
||||
:title="t('redis.commandLine')"
|
||||
:aria-label="t('redis.commandLine')"
|
||||
:aria-pressed="activeView === 'result' && effectiveResultMode === 'console'"
|
||||
@click="selectResultMode('console')"
|
||||
>
|
||||
<SquareTerminal class="block h-3.5 w-3.5 self-center" />
|
||||
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("redis.commandLine") }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
|
||||
<LightTooltip :text="t('tabs.executionSummary')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, formatSqlFileSize, isExternalSqlFileTooLargeError, isSqlFilePath } from "@/lib/sql/sqlFileOpen";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { SqlFileEntry } from "@/lib/backend/api";
|
||||
@@ -256,7 +256,7 @@ async function openFile(path: string) {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
const target = resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
|
||||
} catch (e: any) {
|
||||
if (isExternalSqlFileTooLargeError(e) && isSqlFilePath(path)) {
|
||||
@@ -271,7 +271,7 @@ async function openFile(path: string) {
|
||||
// Open the App-level SQL file execution dialog with this file pre-selected so
|
||||
// the user can review its statements and pick a connection/database before run.
|
||||
function executeFile(path: string) {
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
const target = resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
|
||||
connectionStore.sqlFileSource = {
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowDownWideNarrow, ChevronsDownUp, Download, FilePlus, FileText, FolderCog, FolderClosed, FolderOpen, FolderPlus, Library, Loader2, LocateFixed, Pencil, Play, Search, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { ArrowDownWideNarrow, ArrowRightLeft, ChevronsDownUp, Database, Download, FilePlus, FileText, FolderCog, FolderClosed, FolderOpen, FolderPlus, Layers, Library, Loader2, LocateFixed, Pencil, Play, Search, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import CustomContextMenu, { type ContextMenuItem as CtxMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import HelpTooltip from "@/components/ui/tooltip/HelpTooltip.vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
@@ -20,7 +22,7 @@ import { externalSqlEditorMaxBytes } from "@/lib/sql/sqlFileOpen";
|
||||
import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
|
||||
import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts";
|
||||
import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport";
|
||||
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
|
||||
import { savedSqlBatchErrorMessage, savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
|
||||
import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree";
|
||||
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
|
||||
import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget";
|
||||
@@ -28,6 +30,9 @@ import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/l
|
||||
import { uniqueSavedSqlExportFileName, exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport";
|
||||
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
|
||||
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { catalogDatabaseOptionsKey, normalizedQueryTabCatalog, queryCatalogSelectorVisible, useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import { formatDatabaseLabel } from "@/lib/database/defaultDatabase";
|
||||
import { sqlExecutionTargetCapabilities, targetDefaultDatabase, targetUsesConnectionOnlyScope } from "@/lib/database/sqlExecutionTargetCapabilities";
|
||||
import type { SavedSqlFile, SavedSqlFolder } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -36,6 +41,7 @@ const savedSqlStore = useSavedSqlStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, catalogOptions, loadingCatalogOptions, loadCatalogOptions, catalogDatabaseOptions, loadingCatalogDatabaseOptions, loadCatalogDatabaseOptions } = useDatabaseOptions();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
@@ -516,6 +522,140 @@ const allSelectableItems = computed(() => {
|
||||
|
||||
const hasSelection = computed(() => selectedFileIds.value.size > 0 || selectedFolderIds.value.size > 0);
|
||||
const selectedCount = computed(() => selectedFileIds.value.size + selectedFolderIds.value.size);
|
||||
const showChangeTargetDialog = ref(false);
|
||||
const changeTargetFileIds = ref<string[]>([]);
|
||||
const changeTargetConnectionId = ref("");
|
||||
const changeTargetCatalog = ref("");
|
||||
const changeTargetDatabase = ref("");
|
||||
const changingTarget = ref(false);
|
||||
|
||||
const changeTargetConnections = computed(() => connectionStore.connections.filter((connection) => !!sqlExecutionTargetCapabilities(connection)));
|
||||
const changeTargetConnection = computed(() => changeTargetConnections.value.find((connection) => connection.id === changeTargetConnectionId.value));
|
||||
const changeTargetCapabilities = computed(() => sqlExecutionTargetCapabilities(changeTargetConnection.value));
|
||||
const changeTargetCatalogs = computed(() => catalogOptions.value[changeTargetConnectionId.value] ?? []);
|
||||
const normalizedChangeTargetCatalog = computed(() => normalizedQueryTabCatalog(changeTargetCatalogs.value, changeTargetCatalog.value));
|
||||
const showChangeTargetCatalog = computed(() => {
|
||||
if (!changeTargetCapabilities.value?.supportsCatalog) return false;
|
||||
return changeTargetCatalogs.value.length === 0 || queryCatalogSelectorVisible(changeTargetCatalogs.value);
|
||||
});
|
||||
const changeTargetCatalogNames = computed(() => {
|
||||
const names = changeTargetCatalogs.value.map((catalog) => catalog.name);
|
||||
return changeTargetCatalog.value && !names.includes(changeTargetCatalog.value) ? [changeTargetCatalog.value, ...names] : names;
|
||||
});
|
||||
const changeTargetDatabaseCacheKey = computed(() => (normalizedChangeTargetCatalog.value ? catalogDatabaseOptionsKey(changeTargetConnectionId.value, normalizedChangeTargetCatalog.value) : changeTargetConnectionId.value));
|
||||
const changeTargetDatabaseNames = computed(() => {
|
||||
const names = normalizedChangeTargetCatalog.value ? (catalogDatabaseOptions.value[changeTargetDatabaseCacheKey.value] ?? []) : (databaseOptions.value[changeTargetConnectionId.value] ?? []);
|
||||
return changeTargetDatabase.value && !names.includes(changeTargetDatabase.value) ? [changeTargetDatabase.value, ...names] : names;
|
||||
});
|
||||
const changeTargetDatabaseLoading = computed(() => (normalizedChangeTargetCatalog.value ? (loadingCatalogDatabaseOptions.value[changeTargetDatabaseCacheKey.value] ?? false) : (loadingDatabaseOptions.value[changeTargetConnectionId.value] ?? false)));
|
||||
const showChangeTargetDatabase = computed(() => !!changeTargetCapabilities.value?.supportsDatabase && !targetUsesConnectionOnlyScope(changeTargetConnection.value));
|
||||
const canConfirmChangeTarget = computed(() => {
|
||||
if (changingTarget.value || changeTargetFileIds.value.length === 0 || !changeTargetConnection.value || !changeTargetCapabilities.value) return false;
|
||||
return !changeTargetCapabilities.value.databaseRequired || !!changeTargetDatabase.value;
|
||||
});
|
||||
|
||||
function resetChangeTargetConnection(connectionId: string) {
|
||||
const connection = changeTargetConnections.value.find((candidate) => candidate.id === connectionId);
|
||||
changeTargetConnectionId.value = connection?.id ?? "";
|
||||
changeTargetCatalog.value = "";
|
||||
changeTargetDatabase.value = connection && !targetUsesConnectionOnlyScope(connection) ? targetDefaultDatabase(connection) : "";
|
||||
}
|
||||
|
||||
function openChangeTarget(fileIds: readonly string[]) {
|
||||
const selectedFiles = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file));
|
||||
if (selectedFiles.length === 0) return;
|
||||
changeTargetFileIds.value = selectedFiles.map((file) => file.id);
|
||||
|
||||
const first = selectedFiles[0]!;
|
||||
const commonConnectionId = selectedFiles.every((file) => file.connectionId === first.connectionId) && changeTargetConnections.value.some((connection) => connection.id === first.connectionId) ? first.connectionId : "";
|
||||
const fallbackConnectionId = [connectionStore.activeConnectionId, changeTargetConnections.value[0]?.id].find((id) => !!id && changeTargetConnections.value.some((connection) => connection.id === id)) ?? "";
|
||||
resetChangeTargetConnection(commonConnectionId || fallbackConnectionId);
|
||||
|
||||
if (commonConnectionId) {
|
||||
if (selectedFiles.every((file) => (file.catalog || "") === (first.catalog || ""))) changeTargetCatalog.value = first.catalog || "";
|
||||
if (selectedFiles.every((file) => file.database === first.database)) changeTargetDatabase.value = first.database;
|
||||
}
|
||||
showChangeTargetDialog.value = true;
|
||||
}
|
||||
|
||||
function cancelChangeTarget() {
|
||||
if (changingTarget.value) return;
|
||||
showChangeTargetDialog.value = false;
|
||||
changeTargetFileIds.value = [];
|
||||
}
|
||||
|
||||
async function loadChangeTargetCatalogs() {
|
||||
if (!changeTargetConnection.value || !changeTargetCapabilities.value?.supportsCatalog) return;
|
||||
try {
|
||||
await loadCatalogOptions(changeTargetConnection.value.id);
|
||||
} catch (error) {
|
||||
toast(t("sqlLibrary.changeTargetLoadFailed", { message: savedSqlErrorMessage(error, t) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChangeTargetDatabases() {
|
||||
const connection = changeTargetConnection.value;
|
||||
if (!connection || targetUsesConnectionOnlyScope(connection)) return;
|
||||
try {
|
||||
if (normalizedChangeTargetCatalog.value) await loadCatalogDatabaseOptions(connection.id, normalizedChangeTargetCatalog.value);
|
||||
else await loadDatabaseOptions(connection.id);
|
||||
} catch (error) {
|
||||
toast(t("sqlLibrary.changeTargetLoadFailed", { message: savedSqlErrorMessage(error, t) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function selectChangeTargetCatalog(catalog: string) {
|
||||
if (changeTargetCatalog.value === catalog) return;
|
||||
changeTargetCatalog.value = catalog;
|
||||
changeTargetDatabase.value = "";
|
||||
}
|
||||
|
||||
function changeTargetDatabaseLabel(database: string) {
|
||||
return formatDatabaseLabel(changeTargetConnection.value, database, {
|
||||
defaultDatabase: t("editor.defaultDatabase"),
|
||||
noDatabase: t("editor.noDatabase"),
|
||||
});
|
||||
}
|
||||
|
||||
async function executeChangeTarget() {
|
||||
if (!canConfirmChangeTarget.value) return;
|
||||
const connection = changeTargetConnection.value;
|
||||
if (!connection || !connectionStore.getConfig(connection.id)) {
|
||||
cancelChangeTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
changingTarget.value = true;
|
||||
try {
|
||||
const result = await savedSqlStore.updateFilesExecutionTarget(changeTargetFileIds.value, {
|
||||
connectionId: connection.id,
|
||||
database: targetUsesConnectionOnlyScope(connection) ? "" : changeTargetDatabase.value,
|
||||
catalog: changeTargetCapabilities.value?.supportsCatalog ? normalizedChangeTargetCatalog.value : undefined,
|
||||
});
|
||||
showChangeTargetDialog.value = false;
|
||||
changeTargetFileIds.value = [];
|
||||
|
||||
if (result.failures.length === 0) {
|
||||
if (result.succeeded.length === 0) return;
|
||||
clearSelection();
|
||||
toast(t("sqlLibrary.changeTargetSuccess", { count: result.succeeded.length }), 2500);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFileIds.value = new Set(result.failures.map((failure) => failure.fileId));
|
||||
selectedFolderIds.value = new Set();
|
||||
const message = savedSqlBatchErrorMessage(result.failures, t);
|
||||
if (result.succeeded.length > 0) {
|
||||
toast(t("sqlLibrary.changeTargetPartial", { count: result.succeeded.length, failed: result.failures.length, message }), 8000);
|
||||
} else {
|
||||
toast(t("sqlLibrary.changeTargetFailed", { message }), 8000);
|
||||
}
|
||||
} catch (error) {
|
||||
toast(t("sqlLibrary.changeTargetFailed", { message: savedSqlErrorMessage(error, t) }), 8000);
|
||||
} finally {
|
||||
changingTarget.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedFileIds.value = new Set();
|
||||
@@ -860,6 +1000,12 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
|
||||
if (hasSelection.value) {
|
||||
const selectedFiles = Array.from(selectedFileIds.value);
|
||||
return [
|
||||
{
|
||||
label: t("sqlLibrary.changeTarget", { count: selectedFiles.length }),
|
||||
action: () => openChangeTarget(selectedFiles),
|
||||
icon: ArrowRightLeft,
|
||||
visible: selectedFiles.length > 0,
|
||||
},
|
||||
{
|
||||
label: t("sqlLibrary.moveSelectedToFolder", { count: selectedFiles.length }),
|
||||
icon: FolderClosed,
|
||||
@@ -904,6 +1050,7 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
|
||||
icon: Play,
|
||||
disabled: !hasCurrentSavedSqlExecutionTarget.value,
|
||||
},
|
||||
{ label: t("sqlLibrary.changeTarget", { count: 1 }), action: () => openChangeTarget([target.id]), icon: ArrowRightLeft },
|
||||
{ label: t("sqlLibrary.exportFile"), action: () => exportSingleFile(target), icon: Upload },
|
||||
{ label: t("sqlLibrary.moveToFolder"), icon: FolderClosed, children: folderMoveMenuItems([target.id]) },
|
||||
{ label: "", separator: true },
|
||||
@@ -1479,6 +1626,81 @@ function showDropInside(targetId: string) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="showChangeTargetDialog" @update:open="(open) => (open ? (showChangeTargetDialog = true) : cancelChangeTarget())">
|
||||
<DialogContent class="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("sqlLibrary.changeTargetTitle") }}</DialogTitle>
|
||||
<DialogDescription>{{ t("sqlLibrary.changeTargetDescription", { count: changeTargetFileIds.length }) }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div v-if="changeTargetConnections.length" class="space-y-4 py-2">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium">{{ t("sqlLibrary.targetConnection") }}</label>
|
||||
<ConnectionTreeSelect
|
||||
:model-value="changeTargetConnectionId"
|
||||
:connections="changeTargetConnections"
|
||||
:layout="connectionStore.sidebarLayout"
|
||||
:placeholder="t('editor.selectConnection')"
|
||||
:search-placeholder="t('editor.searchConnection')"
|
||||
:empty-text="t('grid.noSearchResults')"
|
||||
:disabled="changingTarget"
|
||||
trigger-class="h-8 w-full max-w-none justify-between border border-input bg-background px-2.5 text-sm"
|
||||
list-class="w-[410px] max-w-[calc(100vw-2rem)]"
|
||||
@update:model-value="resetChangeTargetConnection"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showChangeTargetCatalog" class="space-y-1.5">
|
||||
<label class="text-sm font-medium">{{ t("sqlLibrary.targetCatalog") }}</label>
|
||||
<SearchableSelect
|
||||
:model-value="changeTargetCatalog"
|
||||
:options="changeTargetCatalogNames"
|
||||
:placeholder="t('editor.selectCatalog')"
|
||||
:search-placeholder="t('editor.searchCatalog')"
|
||||
:empty-text="t('grid.noSearchResults')"
|
||||
:loading-text="t('common.loading')"
|
||||
:loading="loadingCatalogOptions[changeTargetConnectionId] || false"
|
||||
:disabled="changingTarget"
|
||||
@update:model-value="selectChangeTargetCatalog"
|
||||
@update:open="(open: boolean) => open && loadChangeTargetCatalogs()"
|
||||
>
|
||||
<template #trigger-label="{ label, loading }">
|
||||
<Layers class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
<div v-if="showChangeTargetDatabase" class="space-y-1.5">
|
||||
<label class="text-sm font-medium">{{ t("sqlLibrary.targetDatabase") }}</label>
|
||||
<SearchableSelect
|
||||
:model-value="changeTargetDatabase"
|
||||
:options="changeTargetDatabaseNames"
|
||||
:placeholder="t('editor.selectDatabase')"
|
||||
:search-placeholder="t('editor.searchDatabase')"
|
||||
:empty-text="t('grid.noSearchResults')"
|
||||
:loading-text="t('common.loading')"
|
||||
:loading="changeTargetDatabaseLoading"
|
||||
:disabled="changingTarget || !changeTargetConnection"
|
||||
:display-name="changeTargetDatabaseLabel"
|
||||
@update:model-value="(database) => (changeTargetDatabase = database)"
|
||||
@update:open="(open: boolean) => open && loadChangeTargetDatabases()"
|
||||
>
|
||||
<template #trigger-label="{ label, loading }">
|
||||
<Database class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="py-4 text-sm text-muted-foreground">{{ t("sqlLibrary.noTargetConnection") }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" :disabled="changingTarget" @click="cancelChangeTarget">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button size="sm" :disabled="!canConfirmChangeTarget" @click="executeChangeTarget">
|
||||
<Loader2 v-if="changingTarget" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("sqlLibrary.changeTargetConfirm") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Batch Delete Confirmation Dialog -->
|
||||
<Dialog :open="showBatchDeleteConfirm" @update:open="(open) => (showBatchDeleteConfirm = open)">
|
||||
<DialogContent>
|
||||
|
||||
@@ -50,7 +50,12 @@ vi.mock("@/components/ui/tooltip", () => ({
|
||||
TooltipContent: { name: "TooltipContentStub", template: `<span><slot /></span>` },
|
||||
}));
|
||||
vi.mock("@/components/ui/LightDropdown.vue", () => ({
|
||||
default: { name: "LightDropdownStub", template: `<div />` },
|
||||
default: {
|
||||
name: "LightDropdownStub",
|
||||
props: ["items"],
|
||||
emits: ["update:modelValue"],
|
||||
template: `<div data-light-dropdown><button v-for="item in items" :key="item.value" :data-toolbar-menu-item="item.value" :disabled="item.disabled" @click="$emit('update:modelValue', item.value)">{{ item.label }}</button></div>`,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/layout/WindowControls.vue", () => ({
|
||||
default: { name: "WindowControlsStub", template: `<div />` },
|
||||
@@ -215,4 +220,30 @@ describe("always-on-top button visibility", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("exposes backup and MCP shortcuts from the more menu", async () => {
|
||||
const openBackups = vi.fn();
|
||||
const openMcpSettings = vi.fn();
|
||||
const { host, unmount } = mount(AppToolbar, {
|
||||
...toolbarProps,
|
||||
onOpenBackups: openBackups,
|
||||
onOpenMcpSettings: openMcpSettings,
|
||||
});
|
||||
await settled();
|
||||
await nextTick();
|
||||
|
||||
const backupButton = host.querySelector<HTMLButtonElement>('[data-toolbar-menu-item="database-backups"]');
|
||||
const mcpButton = host.querySelector<HTMLButtonElement>('[data-toolbar-menu-item="mcp-settings"]');
|
||||
|
||||
expect(backupButton).not.toBeNull();
|
||||
expect(mcpButton).not.toBeNull();
|
||||
|
||||
backupButton?.click();
|
||||
mcpButton?.click();
|
||||
|
||||
expect(openBackups).toHaveBeenCalledOnce();
|
||||
expect(openMcpSettings).toHaveBeenCalledOnce();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ColumnInfo, ConnectionConfig, QueryResult, QueryTab } from "@/types/database";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import { INFLUXDB_V1_FIELD_COLUMN_EXTRA, INFLUXDB_V1_TAG_COLUMN_EXTRA, INFLUXDB_V1_TIME_COLUMN_EXTRA } from "@/lib/influxdb/influxDbV1Delete";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ executeQuery: vi.fn(), handler: undefined as unknown }));
|
||||
|
||||
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/grid/DataGridColumnLayoutPopover.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/lib/backend/api")>()), executeQuery: mocks.executeQuery }));
|
||||
vi.mock("@/components/grid/DataGrid.vue", () => ({
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
name: "DataGridStub",
|
||||
props: {
|
||||
editable: Boolean,
|
||||
customSaveHandler: Object,
|
||||
result: Object,
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
const handler = props.customSaveHandler as CustomSaveHandler | undefined;
|
||||
mocks.handler = handler;
|
||||
const result = props.result as QueryResult;
|
||||
return h("div", {
|
||||
"data-test": "data-grid",
|
||||
"data-editable": String(props.editable),
|
||||
"data-delete-handler": handler?.confirmation ?? "none",
|
||||
"data-can-update": String(handler?.canUpdate ?? false),
|
||||
"data-reload-on-failure": String(handler?.reloadOnFailure ?? false),
|
||||
"data-readonly-columns": handler?.readonlyColumns?.join(",") ?? "",
|
||||
"data-first-row-deletable": String(handler?.canDeleteRow?.(0, result.rows[0] ?? []) ?? false),
|
||||
});
|
||||
};
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import ContentArea from "../ContentArea.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const mounted: Array<{ app: App<Element>; host: HTMLElement }> = [];
|
||||
|
||||
function column(name: string, extra: string, primary = false): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
data_type: extra === INFLUXDB_V1_TIME_COLUMN_EXTRA ? "timestamp" : "string",
|
||||
is_nullable: extra !== INFLUXDB_V1_TIME_COLUMN_EXTRA,
|
||||
column_default: null,
|
||||
is_primary_key: primary,
|
||||
extra,
|
||||
};
|
||||
}
|
||||
|
||||
function connection(db_type: ConnectionConfig["db_type"], version?: string): ConnectionConfig {
|
||||
return {
|
||||
id: `connection-${db_type}-${version ?? "legacy"}`,
|
||||
name: "Influx test",
|
||||
db_type,
|
||||
host: "localhost",
|
||||
port: 8086,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "metrics",
|
||||
external_config: version === undefined ? undefined : { version },
|
||||
};
|
||||
}
|
||||
|
||||
function dataTab(connectionId: string, options: { tagValue?: string | null; metadataRole?: string } = {}): QueryTab {
|
||||
const columns = [column("time", INFLUXDB_V1_TIME_COLUMN_EXTRA, true), column("host", options.metadataRole ?? INFLUXDB_V1_TAG_COLUMN_EXTRA, true), column("usage", INFLUXDB_V1_FIELD_COLUMN_EXTRA)];
|
||||
return {
|
||||
id: "data-cpu",
|
||||
title: "cpu",
|
||||
connectionId,
|
||||
database: "metrics",
|
||||
mode: "data",
|
||||
sql: "cpu",
|
||||
isExecuting: false,
|
||||
result: {
|
||||
columns: ["time", "host", "usage"],
|
||||
rows: [["2026-09-27T10:11:12Z", options.tagValue === undefined ? "web01" : options.tagValue, 42]],
|
||||
affected_rows: 1,
|
||||
execution_time_ms: 1,
|
||||
},
|
||||
tableMeta: { tableName: "cpu", columns, primaryKeys: ["time", "host"] },
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(connectionConfig: ConnectionConfig, tab = dataTab(connectionConfig.id)) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
useConnectionStore().connections = [connectionConfig];
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ContentArea, {
|
||||
activeTab: tab,
|
||||
activeConnection: connectionConfig,
|
||||
activeOutputView: "result",
|
||||
executableSql: "",
|
||||
formatSqlRequest: null,
|
||||
compressSqlRequest: null,
|
||||
selectedSql: "",
|
||||
cursorPos: 0,
|
||||
blockDangerousRedisCommands: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(pinia);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mounted.push({ app, host });
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
const grid = host.querySelector<HTMLElement>('[data-test="data-grid"]');
|
||||
expect(grid).not.toBeNull();
|
||||
return grid!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mounted.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
mocks.executeQuery.mockReset();
|
||||
mocks.handler = undefined;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("ContentArea InfluxDB 1.x delete gating", () => {
|
||||
it("enables only the delete handler while keeping every value column read-only", async () => {
|
||||
const grid = await mount(connection("influxdb", "1"));
|
||||
|
||||
expect(grid.dataset.editable).toBe("true");
|
||||
expect(grid.dataset.deleteHandler).toBe("influxdb-v1-delete");
|
||||
expect(grid.dataset.canUpdate).toBe("false");
|
||||
expect(grid.dataset.reloadOnFailure).toBe("true");
|
||||
expect(grid.dataset.readonlyColumns).toBe("time,host,usage");
|
||||
expect(grid.dataset.firstRowDeletable).toBe("true");
|
||||
});
|
||||
|
||||
it("executes the guarded preview verbatim and propagates backend failures", async () => {
|
||||
const config = connection("influxdb", "1");
|
||||
await mount(config);
|
||||
const handler = mocks.handler as CustomSaveHandler;
|
||||
const changes = {
|
||||
dirtyRows: new Map(),
|
||||
newRows: [],
|
||||
newRowMeta: [],
|
||||
deletedRows: new Set([0]),
|
||||
columns: ["time", "host", "usage"],
|
||||
rows: [["2026-09-27T10:11:12Z", "web01", 42]],
|
||||
};
|
||||
const statements = await handler.preview!(changes);
|
||||
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 });
|
||||
|
||||
await handler.save(changes);
|
||||
|
||||
expect(mocks.executeQuery).toHaveBeenCalledWith(config.id, "metrics", statements[0]);
|
||||
|
||||
mocks.executeQuery.mockRejectedValueOnce(new Error("InfluxDB rejected the delete"));
|
||||
await expect(handler.save(changes)).rejects.toThrow("InfluxDB rejected the delete");
|
||||
});
|
||||
|
||||
it("keeps a row with a missing tag unavailable for deletion", async () => {
|
||||
const config = connection("influxdb", "1");
|
||||
const grid = await mount(config, dataTab(config.id, { tagValue: null }));
|
||||
|
||||
expect(grid.dataset.deleteHandler).toBe("influxdb-v1-delete");
|
||||
expect(grid.dataset.firstRowDeletable).toBe("false");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ dbType: "influxdb" as const, version: "2", metadataRole: INFLUXDB_V1_TAG_COLUMN_EXTRA, existingEditable: false },
|
||||
{ dbType: "influxdb3" as const, version: "3", metadataRole: INFLUXDB_V1_TAG_COLUMN_EXTRA, existingEditable: true },
|
||||
{ dbType: "influxdb" as const, version: "1", metadataRole: "", existingEditable: false },
|
||||
])("does not add the delete path for an unsupported engine or metadata shape ($dbType/$version)", async ({ dbType, version, metadataRole, existingEditable }) => {
|
||||
const config = connection(dbType, version);
|
||||
const grid = await mount(config, dataTab(config.id, { metadataRole }));
|
||||
|
||||
expect(grid.dataset.editable).toBe(String(existingEditable));
|
||||
expect(grid.dataset.deleteHandler).toBe("none");
|
||||
expect(grid.dataset.firstRowDeletable).toBe("false");
|
||||
});
|
||||
});
|
||||
+14
-1
@@ -42,7 +42,9 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
|
||||
}));
|
||||
|
||||
import EditorGroupTabBar from "../EditorGroupTabBar.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function createHost(): HTMLDivElement {
|
||||
const host = document.createElement("div");
|
||||
@@ -84,6 +86,12 @@ function mountBar(
|
||||
en: {
|
||||
toolbar: { pluginCenter: "Plugin Center" },
|
||||
common: { close: "Close" },
|
||||
connectionGroup: { ungroupedLabel: "Ungrouped" },
|
||||
tabs: {
|
||||
tooltipConnection: "Connection:",
|
||||
tooltipGroup: "Group:",
|
||||
tooltipDatabase: "Database:",
|
||||
},
|
||||
contextMenu: {
|
||||
splitRight: "Split right",
|
||||
splitDown: "Split down",
|
||||
@@ -244,7 +252,10 @@ describe("EditorGroupTabBar behavior", () => {
|
||||
|
||||
it("keeps the tooltip for a connection-bound plugin tab", async () => {
|
||||
const store = useQueryStore();
|
||||
const tabId = store.openPluginWorkbench("com.example.toolbox", "toolbox", { connectionId: "pg-1" });
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [{ id: "plugin-1", name: "SSH server", db_type: "plugin" } as ConnectionConfig];
|
||||
connectionStore.sidebarLayout = { groups: [], order: [{ type: "connection", id: "plugin-1" }] };
|
||||
const tabId = store.openPluginWorkbench("com.example.toolbox", "toolbox", { connectionId: "plugin-1" });
|
||||
const mainGroup = store.groups[0];
|
||||
const { app, host } = mountBar(mainGroup.id, [tabId], tabId, pinia);
|
||||
await settle();
|
||||
@@ -255,6 +266,8 @@ describe("EditorGroupTabBar behavior", () => {
|
||||
|
||||
expect(tooltip.dataset.open).toBe("true");
|
||||
expect(host.querySelector("[data-plugin-title-tooltip]")).toBeNull();
|
||||
expect(host.querySelector<HTMLElement>(".tooltip-content-stub")?.textContent).toContain("SSH server");
|
||||
expect(host.querySelector<HTMLElement>(".tooltip-content-stub")?.textContent).not.toContain("Database:");
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
|
||||
import type { RedisResultViewMode, TabOutputView } from "@/types/database";
|
||||
|
||||
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mounted.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
async function mountSwitcher(canShowRedisConsole: boolean) {
|
||||
const state = reactive<{ view: TabOutputView; mode?: RedisResultViewMode }>({ view: "result" });
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(QueryResultViewSwitcher, {
|
||||
activeView: state.view,
|
||||
canShowResult: true,
|
||||
canShowSummary: false,
|
||||
canShowChart: false,
|
||||
canShowMessages: false,
|
||||
canShowRedisConsole,
|
||||
resultMode: state.mode,
|
||||
onSelectView: (view: TabOutputView) => {
|
||||
state.view = view;
|
||||
},
|
||||
onSelectResultMode: (mode: RedisResultViewMode) => {
|
||||
state.mode = mode;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: { en: { tabs: { tableData: "Table Data", executionSummary: "Summary", messages: "Messages" }, chart: { title: "Chart" }, redis: { commandLine: "Command line" } } },
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mounted.push({ unmount: () => app.unmount(), host });
|
||||
await nextTick();
|
||||
return { host, state };
|
||||
}
|
||||
|
||||
describe("QueryResultViewSwitcher Redis mode", () => {
|
||||
it("defaults Redis results to the grid and switches through the existing toolbar contract", async () => {
|
||||
const { host, state } = await mountSwitcher(true);
|
||||
const grid = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Table Data"));
|
||||
const console = host.querySelector<HTMLButtonElement>('button[aria-label="Command line"]');
|
||||
|
||||
expect(state.mode).toBeUndefined();
|
||||
expect(grid?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(console?.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
console?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(state.mode).toBe("console");
|
||||
expect(state.view).toBe("result");
|
||||
expect(console?.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
grid?.click();
|
||||
await nextTick();
|
||||
expect(state.mode).toBe("grid");
|
||||
expect(grid?.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not expose the Redis-only mode on generic result toolbars", async () => {
|
||||
const { host } = await mountSwitcher(false);
|
||||
expect(host.querySelector('button[aria-label="Command line"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MqBindingInfo, MqExchangeInfo, MqExchangeType, NamespaceRef, TopicInfo } from "@/types/mq";
|
||||
import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExchanges, mqListTopics, mqUnbind } from "@/lib/backend/api";
|
||||
import type { MqBindingInfo, MqExchangeInfo, MqExchangeType, MqListPageRequest, NamespaceRef, TopicInfo } from "@/types/mq";
|
||||
import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExchangesPage, mqListTopicsPage, mqUnbind } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
|
||||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import MqListPagination from "./shared/MqListPagination.vue";
|
||||
|
||||
const RABBITMQ_DEFAULT_PAGE_SIZE = 100;
|
||||
const RABBITMQ_SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -25,6 +29,12 @@ const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const exchangeSearch = ref("");
|
||||
const selectedExchange = ref<MqExchangeInfo>();
|
||||
const exchangePage = ref(1);
|
||||
const exchangePageSize = ref(RABBITMQ_DEFAULT_PAGE_SIZE);
|
||||
const exchangeTotalCount = ref(0);
|
||||
const exchangeHasMore = ref(false);
|
||||
let exchangeLoadSequence = 0;
|
||||
let exchangeSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const bindings = ref<MqBindingInfo[]>([]);
|
||||
const bindingsLoading = ref(false);
|
||||
@@ -58,6 +68,9 @@ const unbindTarget = ref<MqBindingInfo>();
|
||||
const unbinding = ref(false);
|
||||
|
||||
const availableQueues = ref<TopicInfo[]>([]);
|
||||
const availableQueueTotal = ref(0);
|
||||
let queueLoadSequence = 0;
|
||||
let queueSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const filteredExchanges = computed(() => {
|
||||
const query = exchangeSearch.value.trim().toLowerCase();
|
||||
@@ -92,24 +105,60 @@ async function guardWritable(operation: string): Promise<boolean> {
|
||||
async function loadExchanges() {
|
||||
const ns = nsRef();
|
||||
if (!ns) {
|
||||
exchangeLoadSequence += 1;
|
||||
exchanges.value = [];
|
||||
exchangeTotalCount.value = 0;
|
||||
exchangeHasMore.value = false;
|
||||
return;
|
||||
}
|
||||
const loadSequence = ++exchangeLoadSequence;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
exchanges.value = await mqListExchanges(props.connectionId, ns);
|
||||
const pagination: MqListPageRequest = {
|
||||
page: exchangePage.value,
|
||||
pageSize: exchangePageSize.value,
|
||||
search: exchangeSearch.value.trim() || undefined,
|
||||
sort: "name",
|
||||
sortDescending: false,
|
||||
};
|
||||
const result = await mqListExchangesPage(props.connectionId, ns, pagination);
|
||||
if (loadSequence !== exchangeLoadSequence) return;
|
||||
const lastPage = Math.max(1, Math.ceil(result.totalCount / result.pageSize));
|
||||
if (result.page > lastPage) {
|
||||
exchangePage.value = lastPage;
|
||||
void loadExchanges();
|
||||
return;
|
||||
}
|
||||
exchanges.value = result.items;
|
||||
exchangePage.value = result.page;
|
||||
exchangePageSize.value = result.pageSize;
|
||||
exchangeTotalCount.value = result.totalCount;
|
||||
exchangeHasMore.value = result.hasMore;
|
||||
if (selectedExchange.value && !exchanges.value.some((exchange) => exchange.name === selectedExchange.value?.name && exchange.namespace === selectedExchange.value?.namespace)) {
|
||||
selectedExchange.value = undefined;
|
||||
bindings.value = [];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (loadSequence !== exchangeLoadSequence) return;
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (loadSequence === exchangeLoadSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeExchangePage(page: number) {
|
||||
if (page < 1 || page === exchangePage.value) return;
|
||||
exchangePage.value = page;
|
||||
void loadExchanges();
|
||||
}
|
||||
|
||||
function changeExchangePageSize(pageSize: number) {
|
||||
exchangePageSize.value = pageSize;
|
||||
exchangePage.value = 1;
|
||||
void loadExchanges();
|
||||
}
|
||||
|
||||
async function loadBindings(exchange: MqExchangeInfo) {
|
||||
const ns = nsRefFor(exchange);
|
||||
if (!ns) return;
|
||||
@@ -124,12 +173,28 @@ async function loadBindings(exchange: MqExchangeInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueues() {
|
||||
async function loadQueues(search = "") {
|
||||
const ns = nsRefFor(selectedExchange.value);
|
||||
if (!ns) return;
|
||||
const loadSequence = ++queueLoadSequence;
|
||||
try {
|
||||
availableQueues.value = await mqListTopics(props.connectionId, ns, { includeNonPersistent: false });
|
||||
const result = await mqListTopicsPage(
|
||||
props.connectionId,
|
||||
ns,
|
||||
{ includeNonPersistent: false },
|
||||
{
|
||||
page: 1,
|
||||
pageSize: RABBITMQ_DEFAULT_PAGE_SIZE,
|
||||
search: search.trim() || undefined,
|
||||
sort: "name",
|
||||
sortDescending: false,
|
||||
},
|
||||
);
|
||||
if (loadSequence !== queueLoadSequence) return;
|
||||
availableQueues.value = result.items;
|
||||
availableQueueTotal.value = result.totalCount;
|
||||
} catch (e: unknown) {
|
||||
if (loadSequence !== queueLoadSequence) return;
|
||||
console.warn("[DBX] Failed to load queues for binding dialog:", e);
|
||||
}
|
||||
}
|
||||
@@ -224,7 +289,6 @@ function openBindDialog() {
|
||||
if (!selectedExchange.value) return;
|
||||
dialogError.value = undefined;
|
||||
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
|
||||
void loadQueues();
|
||||
showBindDialog.value = true;
|
||||
}
|
||||
|
||||
@@ -312,20 +376,50 @@ function formatBindingArguments(bindingRow: MqBindingInfo): string {
|
||||
watch(
|
||||
() => [props.tenant, props.namespace],
|
||||
() => {
|
||||
if (exchangeSearchTimer) clearTimeout(exchangeSearchTimer);
|
||||
exchangePage.value = 1;
|
||||
selectedExchange.value = undefined;
|
||||
bindings.value = [];
|
||||
loadExchanges();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(exchangeSearch, () => {
|
||||
if (exchangeSearchTimer) clearTimeout(exchangeSearchTimer);
|
||||
exchangeSearchTimer = setTimeout(() => {
|
||||
exchangeSearchTimer = undefined;
|
||||
exchangePage.value = 1;
|
||||
void loadExchanges();
|
||||
}, RABBITMQ_SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [bindForm.value.destination, bindForm.value.destinationType, showBindDialog.value] as const,
|
||||
([destination, destinationType, open]) => {
|
||||
if (queueSearchTimer) clearTimeout(queueSearchTimer);
|
||||
if (!open || destinationType !== "queue") return;
|
||||
queueSearchTimer = setTimeout(() => {
|
||||
queueSearchTimer = undefined;
|
||||
void loadQueues(destination);
|
||||
}, RABBITMQ_SEARCH_DEBOUNCE_MS);
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
exchangeLoadSequence += 1;
|
||||
queueLoadSequence += 1;
|
||||
if (exchangeSearchTimer) clearTimeout(exchangeSearchTimer);
|
||||
if (queueSearchTimer) clearTimeout(queueSearchTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="exchanges-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<input v-model="exchangeSearch" type="search" class="exchange-search" :placeholder="t('mqExchanges.searchPlaceholder')" :disabled="loading && !exchanges.length" />
|
||||
<span v-if="exchanges.length" class="exchange-count">{{ filteredExchanges.length }} / {{ exchanges.length }}</span>
|
||||
<input v-model="exchangeSearch" type="search" class="exchange-search" :placeholder="t('mqExchanges.searchPlaceholder')" :disabled="loading && !exchanges.length && !exchangeSearch" />
|
||||
<span v-if="exchanges.length" class="exchange-count">{{ filteredExchanges.length }} / {{ exchangeTotalCount }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="loadExchanges" :disabled="loading || !tenant || !namespace" class="btn-secondary">
|
||||
@@ -341,7 +435,7 @@ watch(
|
||||
|
||||
<div v-else-if="loading && !exchanges.length" class="panel-loading">{{ t("mqExchanges.loading") }}</div>
|
||||
|
||||
<div v-else-if="!exchanges.length" class="panel-placeholder">{{ t("mqExchanges.noExchanges") }}</div>
|
||||
<div v-else-if="!exchanges.length" class="panel-placeholder">{{ exchangeSearch.trim() ? t("mqExchanges.noMatches") : t("mqExchanges.noExchanges") }}</div>
|
||||
|
||||
<div v-else-if="!filteredExchanges.length" class="panel-placeholder">{{ t("mqExchanges.noMatches") }}</div>
|
||||
|
||||
@@ -382,6 +476,8 @@ watch(
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<MqListPagination v-if="exchangeTotalCount > exchangePageSize || exchangePage > 1" :page="exchangePage" :page-size="exchangePageSize" :total-count="exchangeTotalCount" :has-more="exchangeHasMore" :loading="loading" @page-change="changeExchangePage" @page-size-change="changeExchangePageSize" />
|
||||
|
||||
<!-- Bindings of the selected exchange -->
|
||||
<div v-if="selectedExchange" class="bindings-section">
|
||||
<div class="bindings-header">
|
||||
@@ -495,6 +591,7 @@ watch(
|
||||
<datalist id="mq-exchange-bind-queues">
|
||||
<option v-for="queue in availableQueues" :key="queue.name" :value="queue.shortName" />
|
||||
</datalist>
|
||||
<small v-if="bindForm.destinationType === 'queue' && availableQueueTotal > availableQueues.length" class="queue-suggestion-count">{{ availableQueues.length }} / {{ availableQueueTotal }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t("mqExchanges.routingKey") }}</label>
|
||||
@@ -591,6 +688,13 @@ watch(
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.queue-suggestion-count {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-loading {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { ref, watch, computed, onBeforeUnmount } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
|
||||
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
|
||||
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqListPageRequest, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
|
||||
import { mqListTopics, mqListTopicsPage, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
|
||||
import type { ClusterInfo } from "@/types/mq";
|
||||
import RocketMqTopicDialogs, { type RocketMqTopicDialogKind } from "./rocketmq/RocketMqTopicDialogs.vue";
|
||||
import SendMessagePanel from "./SendMessagePanel.vue";
|
||||
@@ -17,8 +17,11 @@ import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import MqListPagination from "./shared/MqListPagination.vue";
|
||||
|
||||
const TOPIC_ROW_HEIGHT = 44;
|
||||
const RABBITMQ_DEFAULT_PAGE_SIZE = 100;
|
||||
const RABBITMQ_SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
type VirtualTopicRow = {
|
||||
id: string;
|
||||
@@ -98,6 +101,12 @@ const showNamespaceColumn = computed(() => isRabbitMqCluster.value && isAllVhost
|
||||
const showRabbitMqSubTabs = computed(() => isRabbitMqCluster.value && props.supportsExchanges === true);
|
||||
const rabbitMqSubTab = ref<"queues" | "exchanges">("queues");
|
||||
const rabbitMqMessageSort = ref<"asc" | "desc" | null>(null);
|
||||
const rabbitMqPage = ref(1);
|
||||
const rabbitMqPageSize = ref(RABBITMQ_DEFAULT_PAGE_SIZE);
|
||||
const rabbitMqTotalCount = ref(0);
|
||||
const rabbitMqHasMore = ref(false);
|
||||
let topicLoadSequence = 0;
|
||||
let topicSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const rocketMqTopicTypeOptions = ROCKETMQ_TOPIC_MESSAGE_TYPES;
|
||||
const rocketMqCreatableTopicTypes = ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES;
|
||||
const rocketMqClusterName = computed(() => clusterInfo.value?.clusterId ?? "-");
|
||||
@@ -224,6 +233,8 @@ function rabbitMqRatesTitle(): string {
|
||||
|
||||
function toggleRabbitMqMessageSort() {
|
||||
rabbitMqMessageSort.value = rabbitMqMessageSort.value === "desc" ? "asc" : "desc";
|
||||
rabbitMqPage.value = 1;
|
||||
void loadTopics();
|
||||
}
|
||||
|
||||
function topicTypeLabel(topic: TopicInfo): string {
|
||||
@@ -269,9 +280,13 @@ async function guardWritable(operation: string): Promise<boolean> {
|
||||
|
||||
async function loadTopics() {
|
||||
if (!props.tenant || !props.namespace) {
|
||||
topicLoadSequence += 1;
|
||||
topics.value = [];
|
||||
rabbitMqTotalCount.value = 0;
|
||||
rabbitMqHasMore.value = false;
|
||||
return;
|
||||
}
|
||||
const loadSequence = ++topicLoadSequence;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
try {
|
||||
@@ -282,14 +297,53 @@ async function loadTopics() {
|
||||
const opts: ListTopicsOpts = {
|
||||
includeNonPersistent: includeNonPersistent.value,
|
||||
};
|
||||
topics.value = await mqListTopics(props.connectionId, ns, opts);
|
||||
if (isRabbitMqCluster.value) {
|
||||
const pagination: MqListPageRequest = {
|
||||
page: rabbitMqPage.value,
|
||||
pageSize: rabbitMqPageSize.value,
|
||||
search: topicSearch.value.trim() || undefined,
|
||||
sort: rabbitMqMessageSort.value ? "messagesReady" : "name",
|
||||
sortDescending: rabbitMqMessageSort.value === "desc",
|
||||
};
|
||||
const result = await mqListTopicsPage(props.connectionId, ns, opts, pagination);
|
||||
if (loadSequence !== topicLoadSequence) return;
|
||||
const lastPage = Math.max(1, Math.ceil(result.totalCount / result.pageSize));
|
||||
if (result.page > lastPage) {
|
||||
rabbitMqPage.value = lastPage;
|
||||
void loadTopics();
|
||||
return;
|
||||
}
|
||||
topics.value = result.items;
|
||||
rabbitMqPage.value = result.page;
|
||||
rabbitMqPageSize.value = result.pageSize;
|
||||
rabbitMqTotalCount.value = result.totalCount;
|
||||
rabbitMqHasMore.value = result.hasMore;
|
||||
} else {
|
||||
topics.value = await mqListTopics(props.connectionId, ns, opts);
|
||||
if (loadSequence !== topicLoadSequence) return;
|
||||
rabbitMqTotalCount.value = topics.value.length;
|
||||
rabbitMqHasMore.value = false;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (loadSequence !== topicLoadSequence) return;
|
||||
error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (loadSequence === topicLoadSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeRabbitMqPage(page: number) {
|
||||
if (page < 1 || page === rabbitMqPage.value) return;
|
||||
rabbitMqPage.value = page;
|
||||
void loadTopics();
|
||||
}
|
||||
|
||||
function changeRabbitMqPageSize(pageSize: number) {
|
||||
rabbitMqPageSize.value = pageSize;
|
||||
rabbitMqPage.value = 1;
|
||||
void loadTopics();
|
||||
}
|
||||
|
||||
async function loadClusterInfo() {
|
||||
if (!isRocketMqCluster.value) return;
|
||||
try {
|
||||
@@ -513,6 +567,8 @@ function normalizePartitionInput() {
|
||||
watch(
|
||||
() => [props.tenant, props.namespace],
|
||||
() => {
|
||||
if (topicSearchTimer) clearTimeout(topicSearchTimer);
|
||||
rabbitMqPage.value = 1;
|
||||
selectedTopic.value = undefined;
|
||||
loadTopics();
|
||||
if (isRocketMqCluster.value) void loadClusterInfo();
|
||||
@@ -529,14 +585,30 @@ watch(
|
||||
);
|
||||
|
||||
watch(includeNonPersistent, () => {
|
||||
rabbitMqPage.value = 1;
|
||||
loadTopics();
|
||||
});
|
||||
|
||||
watch(topicSearch, () => {
|
||||
if (!isRabbitMqCluster.value) return;
|
||||
if (topicSearchTimer) clearTimeout(topicSearchTimer);
|
||||
topicSearchTimer = setTimeout(() => {
|
||||
topicSearchTimer = undefined;
|
||||
rabbitMqPage.value = 1;
|
||||
void loadTopics();
|
||||
}, RABBITMQ_SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
watch(newPartitions, () => {
|
||||
if (dialogError.value === t("mqTopics.partitionMustIncrease") && canSubmitPartitionUpdate.value) {
|
||||
dialogError.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
topicLoadSequence += 1;
|
||||
if (topicSearchTimer) clearTimeout(topicSearchTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -552,8 +624,8 @@ watch(newPartitions, () => {
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3>{{ t("mqTopics.title") }}</h3>
|
||||
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length" />
|
||||
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ typeFilteredTopics.length }} </span>
|
||||
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length && !topicSearch" />
|
||||
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ isRabbitMqCluster ? rabbitMqTotalCount : typeFilteredTopics.length }} </span>
|
||||
<label v-if="isKafkaCluster" class="checkbox-label">
|
||||
<input v-model="includeSystemTopics" type="checkbox" />
|
||||
{{ t("mqTopics.includeSystemTopics") }}
|
||||
@@ -584,7 +656,7 @@ watch(newPartitions, () => {
|
||||
|
||||
<div v-else-if="loading && !topics.length" class="panel-loading">{{ t("mqTopics.loading") }}</div>
|
||||
|
||||
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
|
||||
<div v-else-if="!topics.length" class="panel-placeholder">{{ isRabbitMqCluster && topicSearch.trim() ? t("mqTopics.noMatches") : t("mqTopics.noTopics") }}</div>
|
||||
|
||||
<div v-else-if="!filteredTopics.length" class="panel-placeholder">
|
||||
{{ isRocketMqCluster && userTopicCount === 0 ? t("mqTopics.noUserTopics") : isKafkaCluster && !includeSystemTopics && userTopicCount === 0 ? t("mqTopics.noUserTopics") : t("mqTopics.noMatches") }}
|
||||
@@ -690,6 +762,17 @@ watch(newPartitions, () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MqListPagination
|
||||
v-if="isRabbitMqCluster && (rabbitMqTotalCount > rabbitMqPageSize || rabbitMqPage > 1)"
|
||||
:page="rabbitMqPage"
|
||||
:page-size="rabbitMqPageSize"
|
||||
:total-count="rabbitMqTotalCount"
|
||||
:has-more="rabbitMqHasMore"
|
||||
:loading="loading"
|
||||
@page-change="changeRabbitMqPage"
|
||||
@page-size-change="changeRabbitMqPageSize"
|
||||
/>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
mqBind: vi.fn(),
|
||||
mqCreateExchange: vi.fn(),
|
||||
mqDeleteExchange: vi.fn(),
|
||||
mqListBindings: vi.fn(),
|
||||
mqListExchangesPage: vi.fn(),
|
||||
mqListTopicsPage: vi.fn(),
|
||||
mqUnbind: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string, params?: { page?: number }) => (params?.page ? `${key}:${params.page}` : key) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => backend);
|
||||
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
|
||||
default: defineComponent({ setup: () => () => h("div") }),
|
||||
}));
|
||||
|
||||
import ExchangesPanel from "@/components/mq/ExchangesPanel.vue";
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function mountPanel(namespace = "/") {
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(ExchangesPanel, {
|
||||
connectionId: "rabbit-1",
|
||||
tenant: "_rabbitmq",
|
||||
namespace,
|
||||
});
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
return root;
|
||||
}
|
||||
|
||||
function exchangeNames(container: ParentNode): string[] {
|
||||
return [...container.querySelectorAll<HTMLElement>(".exchange-name-cell > span:first-child")].map((item) => item.textContent ?? "");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.values(backend).forEach((mock) => mock.mockReset());
|
||||
backend.mqListBindings.mockResolvedValue([]);
|
||||
backend.mqListTopicsPage.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, hasMore: false });
|
||||
backend.mqListExchangesPage.mockResolvedValue({
|
||||
items: [{ name: "events", type: "topic", durable: true, autoDelete: false, internal: false }],
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
totalCount: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ExchangesPanel RabbitMQ pagination", () => {
|
||||
it("requests one page at a time and retains all-vhost row identity", async () => {
|
||||
backend.mqListExchangesPage
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ name: "events", namespace: "/", type: "topic", durable: true, autoDelete: false, internal: false }],
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
totalCount: 101,
|
||||
hasMore: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ name: "events", namespace: "/staging", type: "topic", durable: true, autoDelete: false, internal: false }],
|
||||
page: 2,
|
||||
pageSize: 100,
|
||||
totalCount: 101,
|
||||
hasMore: false,
|
||||
});
|
||||
const panel = await mountPanel("*");
|
||||
|
||||
expect(exchangeNames(panel)).toEqual(["events"]);
|
||||
expect(panel.querySelector("tbody td:nth-child(2)")?.textContent?.trim()).toBe("/");
|
||||
const next = panel.querySelector<HTMLButtonElement>('[data-testid="mq-list-next"]');
|
||||
if (!next) throw new Error("next page button not found");
|
||||
next.click();
|
||||
await flushUi();
|
||||
|
||||
expect(panel.querySelector("tbody td:nth-child(2)")?.textContent?.trim()).toBe("/staging");
|
||||
expect(backend.mqListExchangesPage).toHaveBeenLastCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "*" }, expect.objectContaining({ page: 2, pageSize: 100, sort: "name" }));
|
||||
});
|
||||
|
||||
it("debounces search into the server-side page request", async () => {
|
||||
vi.useFakeTimers();
|
||||
const panel = await mountPanel();
|
||||
backend.mqListExchangesPage.mockClear();
|
||||
backend.mqListExchangesPage.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, hasMore: false });
|
||||
|
||||
const search = panel.querySelector<HTMLInputElement>(".exchange-search");
|
||||
if (!search) throw new Error("exchange search not found");
|
||||
search.value = "orders";
|
||||
search.dispatchEvent(new Event("input"));
|
||||
await nextTick();
|
||||
expect(backend.mqListExchangesPage).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await flushUi();
|
||||
expect(backend.mqListExchangesPage).toHaveBeenCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, expect.objectContaining({ page: 1, search: "orders" }));
|
||||
});
|
||||
});
|
||||
+123
-36
@@ -5,6 +5,7 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
mqListTopics: vi.fn(),
|
||||
mqListTopicsPage: vi.fn(),
|
||||
mqCreateTopic: vi.fn(),
|
||||
mqDeleteTopic: vi.fn(),
|
||||
mqUpdatePartitions: vi.fn(),
|
||||
@@ -99,42 +100,64 @@ async function mountRabbitMqAllVhostsPanel() {
|
||||
return root;
|
||||
}
|
||||
|
||||
async function mountKafkaPanel() {
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(TopicsPanel, {
|
||||
connectionId: "kafka-1",
|
||||
tenant: "_flat_mq",
|
||||
namespace: "_flat_mq",
|
||||
mqSystemKind: "kafka",
|
||||
isFlatMqCluster: true,
|
||||
});
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
return root;
|
||||
}
|
||||
|
||||
function rowNames(container: ParentNode): string[] {
|
||||
return [...container.querySelectorAll<HTMLElement>(".topic-name-text")].map((item) => item.textContent ?? "");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.values(backend).forEach((mock) => mock.mockReset());
|
||||
backend.mqListTopics.mockResolvedValue([
|
||||
{
|
||||
name: "alpha",
|
||||
shortName: "alpha",
|
||||
partitioned: false,
|
||||
persistent: true,
|
||||
messageCount: 4,
|
||||
messagesReady: 3,
|
||||
messagesUnacked: 1,
|
||||
queueType: "quorum",
|
||||
state: "running",
|
||||
autoDelete: true,
|
||||
consumerCount: 2,
|
||||
arguments: { "x-queue-type": "quorum", "x-message-ttl": 60000, "x-dead-letter-exchange": "dlx" },
|
||||
publishRate: 12.5,
|
||||
deliverRate: 11.8,
|
||||
ackRate: 11.2,
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
shortName: "beta",
|
||||
partitioned: false,
|
||||
persistent: true,
|
||||
messageCount: 12,
|
||||
messagesReady: 12,
|
||||
messagesUnacked: 0,
|
||||
// No queue type, no arguments, and no message_stats sample:
|
||||
// type stays unknown, rates render as "-" (never fabricated zeros).
|
||||
},
|
||||
]);
|
||||
backend.mqListTopicsPage.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
name: "alpha",
|
||||
shortName: "alpha",
|
||||
partitioned: false,
|
||||
persistent: true,
|
||||
messageCount: 4,
|
||||
messagesReady: 3,
|
||||
messagesUnacked: 1,
|
||||
queueType: "quorum",
|
||||
state: "running",
|
||||
autoDelete: true,
|
||||
consumerCount: 2,
|
||||
arguments: { "x-queue-type": "quorum", "x-message-ttl": 60000, "x-dead-letter-exchange": "dlx" },
|
||||
publishRate: 12.5,
|
||||
deliverRate: 11.8,
|
||||
ackRate: 11.2,
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
shortName: "beta",
|
||||
partitioned: false,
|
||||
persistent: true,
|
||||
messageCount: 12,
|
||||
messagesReady: 12,
|
||||
messagesUnacked: 0,
|
||||
// No queue type, no arguments, and no message_stats sample:
|
||||
// type stays unknown, rates render as "-" (never fabricated zeros).
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
totalCount: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -142,6 +165,7 @@ afterEach(() => {
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("TopicsPanel RabbitMQ queue messages", () => {
|
||||
@@ -155,11 +179,12 @@ describe("TopicsPanel RabbitMQ queue messages", () => {
|
||||
if (!sortButton) throw new Error("RabbitMQ message sort button not found");
|
||||
|
||||
sortButton.click();
|
||||
await nextTick();
|
||||
await flushUi();
|
||||
expect(rowNames(panel)).toEqual(["beta", "alpha"]);
|
||||
expect(backend.mqListTopicsPage).toHaveBeenLastCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, { includeNonPersistent: false }, expect.objectContaining({ page: 1, pageSize: 100, sort: "messagesReady", sortDescending: true }));
|
||||
|
||||
sortButton.click();
|
||||
await nextTick();
|
||||
await flushUi();
|
||||
expect(rowNames(panel)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
@@ -203,10 +228,16 @@ describe("TopicsPanel RabbitMQ queue messages", () => {
|
||||
});
|
||||
|
||||
it("keeps per-queue vhosts separate in all-vhosts mode (#5984 regression guard)", async () => {
|
||||
backend.mqListTopics.mockResolvedValue([
|
||||
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/", messageCount: 3, messagesReady: 3 },
|
||||
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/staging", messageCount: 7, messagesReady: 7 },
|
||||
]);
|
||||
backend.mqListTopicsPage.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/", messageCount: 3, messagesReady: 3 },
|
||||
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/staging", messageCount: 7, messagesReady: 7 },
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
totalCount: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
const panel = await mountRabbitMqAllVhostsPanel();
|
||||
|
||||
// Both rows are named "orders" but must stay distinct per virtual host;
|
||||
@@ -216,4 +247,60 @@ describe("TopicsPanel RabbitMQ queue messages", () => {
|
||||
const counts = [...panel.querySelectorAll<HTMLElement>('[data-testid="rabbitmq-message-count"]')].map((item) => item.textContent?.trim());
|
||||
expect(counts).toEqual(["3", "7"]);
|
||||
});
|
||||
|
||||
it("loads only the requested RabbitMQ page and keeps the total visible", async () => {
|
||||
backend.mqListTopicsPage
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ name: "page-one", shortName: "page-one", partitioned: false, persistent: true }],
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
totalCount: 101,
|
||||
hasMore: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ name: "page-two", shortName: "page-two", partitioned: false, persistent: true }],
|
||||
page: 2,
|
||||
pageSize: 100,
|
||||
totalCount: 101,
|
||||
hasMore: false,
|
||||
});
|
||||
const panel = await mountRabbitMqPanel();
|
||||
|
||||
expect(rowNames(panel)).toEqual(["page-one"]);
|
||||
expect(panel.querySelector(".topic-count")?.textContent?.replace(/\s+/g, " ").trim()).toBe("1 / 101");
|
||||
const next = panel.querySelector<HTMLButtonElement>('[data-testid="mq-list-next"]');
|
||||
if (!next) throw new Error("next page button not found");
|
||||
next.click();
|
||||
await flushUi();
|
||||
|
||||
expect(rowNames(panel)).toEqual(["page-two"]);
|
||||
expect(backend.mqListTopicsPage).toHaveBeenLastCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, { includeNonPersistent: false }, expect.objectContaining({ page: 2, pageSize: 100 }));
|
||||
});
|
||||
|
||||
it("debounces queue search into the bounded backend request", async () => {
|
||||
vi.useFakeTimers();
|
||||
const panel = await mountRabbitMqPanel();
|
||||
backend.mqListTopicsPage.mockClear();
|
||||
backend.mqListTopicsPage.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, hasMore: false });
|
||||
|
||||
const search = panel.querySelector<HTMLInputElement>(".topic-search");
|
||||
if (!search) throw new Error("topic search not found");
|
||||
search.value = "orders";
|
||||
search.dispatchEvent(new Event("input"));
|
||||
await nextTick();
|
||||
expect(backend.mqListTopicsPage).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await flushUi();
|
||||
expect(backend.mqListTopicsPage).toHaveBeenCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, { includeNonPersistent: false }, expect.objectContaining({ page: 1, search: "orders" }));
|
||||
});
|
||||
|
||||
it("keeps non-RabbitMQ providers on the legacy unbounded contract", async () => {
|
||||
backend.mqListTopics.mockResolvedValue([{ name: "orders", shortName: "orders", partitioned: false, persistent: true }]);
|
||||
const panel = await mountKafkaPanel();
|
||||
|
||||
expect(rowNames(panel)).toEqual(["orders"]);
|
||||
expect(backend.mqListTopics).toHaveBeenCalledWith("kafka-1", { tenant: "_flat_mq", namespace: "_flat_mq" }, { includeNonPersistent: false });
|
||||
expect(backend.mqListTopicsPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
pageChange: [page: number];
|
||||
pageSizeChange: [pageSize: number];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const pageSizeOptions = [50, 100, 200, 500];
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.totalCount / props.pageSize)));
|
||||
const rangeStart = computed(() => (props.totalCount ? (props.page - 1) * props.pageSize + 1 : 0));
|
||||
const rangeEnd = computed(() => Math.min(props.page * props.pageSize, props.totalCount));
|
||||
|
||||
function changePageSize(event: Event) {
|
||||
const pageSize = Number((event.target as HTMLSelectElement).value);
|
||||
if (Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize !== props.pageSize) {
|
||||
emit("pageSizeChange", pageSize);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mq-list-pagination" data-testid="mq-list-pagination">
|
||||
<span class="mq-list-range">{{ rangeStart }}–{{ rangeEnd }} / {{ totalCount }}</span>
|
||||
<label class="mq-list-page-size">
|
||||
<span>{{ t("grid.rowsPerPage") }}</span>
|
||||
<select :value="pageSize" :disabled="loading" @change="changePageSize">
|
||||
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="mq-list-page-button" data-testid="mq-list-previous" :aria-label="t('grid.page', { page: Math.max(1, page - 1) })" :disabled="loading || page <= 1" @click="emit('pageChange', page - 1)">‹</button>
|
||||
<span class="mq-list-page-number">{{ t("grid.page", { page }) }} / {{ pageCount }}</span>
|
||||
<button type="button" class="mq-list-page-button" data-testid="mq-list-next" :aria-label="t('grid.page', { page: page + 1 })" :disabled="loading || !hasMore" @click="emit('pageChange', page + 1)">›</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mq-list-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mq-list-range,
|
||||
.mq-list-page-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mq-list-page-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mq-list-page-size select,
|
||||
.mq-list-page-button {
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mq-list-page-size select {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.mq-list-page-button {
|
||||
min-width: 30px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mq-list-page-button:disabled,
|
||||
.mq-list-page-size select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -101,7 +101,7 @@ import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRe
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
|
||||
import { showSqlInsertModeDialog, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
import { showSqlInsertModeDialog, type SqlInsertDialect, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
|
||||
import {
|
||||
defaultPasteTableMode,
|
||||
@@ -2321,14 +2321,14 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json") {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
|
||||
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql", insertDialect: SqlInsertDialect = "source") {
|
||||
if (format === "json") {
|
||||
await exportDataLegacy(row, format);
|
||||
return;
|
||||
}
|
||||
const sqlExportOptions = format === "sql" ? await showSqlInsertModeDialog({ allowSplit: true }) : undefined;
|
||||
if (format === "sql" && sqlExportOptions === null) return;
|
||||
await exportTableData(row, format, undefined, "name", true, sqlExportOptions?.insertMode ?? "batch", sqlExportOptions?.splitMaxMb);
|
||||
await exportTableData(row, format, undefined, "name", true, sqlExportOptions?.insertMode ?? "batch", sqlExportOptions?.splitMaxMb, insertDialect);
|
||||
}
|
||||
|
||||
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<XlsxExportOptions | null> {
|
||||
@@ -2371,7 +2371,7 @@ async function exportDataXlsx(row: ObjectBrowserRow) {
|
||||
await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter);
|
||||
}
|
||||
|
||||
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode: SqlInsertMode = "batch", splitMaxMb?: number) {
|
||||
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode: SqlInsertMode = "batch", splitMaxMb?: number, insertDialect: SqlInsertDialect = "source") {
|
||||
const schema = row.schema || selectedSchema.value;
|
||||
const splitSqlOutput = format === "sql" && splitMaxMb !== undefined;
|
||||
|
||||
@@ -2446,7 +2446,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
|
||||
tableName: row.name,
|
||||
filePath,
|
||||
format,
|
||||
...(format === "sql" ? { insertMode, splitMaxMb } : {}),
|
||||
...(format === "sql" ? { insertMode, insertDialect, splitMaxMb } : {}),
|
||||
csvQuoteMode: settingsStore.editorSettings.csvQuoteMode,
|
||||
columns,
|
||||
columnComments: format === "xlsx" ? columnComments : undefined,
|
||||
@@ -3373,7 +3373,10 @@ function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
|
||||
{ label: "CSV", action: () => exportData(item, "csv") },
|
||||
{ label: "JSON", action: () => exportData(item, "json") },
|
||||
];
|
||||
if (!isVictoriaMetrics.value) formats.push({ label: "SQL INSERT", action: () => exportData(item, "sql") });
|
||||
if (!isVictoriaMetrics.value) {
|
||||
formats.push({ label: "SQL INSERT", action: () => exportData(item, "sql", "source") });
|
||||
formats.push({ label: t("contextMenu.standardSqlInsert"), action: () => exportData(item, "sql", "standard") });
|
||||
}
|
||||
formats.push({ label: "XLSX", action: () => exportDataXlsx(item) });
|
||||
return {
|
||||
label: t("contextMenu.exportData"),
|
||||
|
||||
+100
-3
@@ -1,14 +1,14 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, ref, type App } from "vue";
|
||||
import { createApp, defineComponent, h, inject, provide, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn(), invokePlugin: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn(), invokePlugin: vi.fn(), useConnectionStore: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.listPlugins, invokePlugin: mocks.invokePlugin }));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: mocks.useConnectionStore }));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: defineComponent(
|
||||
@@ -35,6 +35,37 @@ vi.mock("@/components/ui/tooltip", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("./PluginIcon.vue", () => ({ default: defineComponent(() => () => h("span")) }));
|
||||
vi.mock("@/components/icons/ConnectionIcon.vue", () => ({ default: defineComponent(() => () => h("span", { "data-connection-icon-stub": "" })) }));
|
||||
// DropdownMenu harness: content renders unconditionally and the trigger's
|
||||
// click forwards `update:open`, so picker tests assert menu content and item
|
||||
// selection without depending on reka-ui's floating/portal behavior.
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: defineComponent({
|
||||
props: { open: { type: Boolean, default: false } },
|
||||
emits: ["update:open"],
|
||||
setup(_, { slots, emit }) {
|
||||
provide("dbxDropdownTestSetOpen", (value: boolean) => emit("update:open", value));
|
||||
return () => h("div", { "data-dropdown-menu-stub": "" }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
DropdownMenuTrigger: defineComponent({
|
||||
setup(_, { slots }) {
|
||||
const setOpen = inject<(value: boolean) => void>("dbxDropdownTestSetOpen")!;
|
||||
return () => h("div", { onClick: () => setOpen(true) }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
DropdownMenuContent: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", { "data-plugin-dock-plus-menu": "" }, slots.default?.()),
|
||||
),
|
||||
DropdownMenuItem: defineComponent({
|
||||
emits: ["select"],
|
||||
setup(_, { slots, emit }) {
|
||||
return () => h("div", { "data-dropdown-item-stub": "", onClick: () => emit("select", {}) }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
// Mirrors the real PluginWorkbenchHost, whose title computed dereferences
|
||||
// props.contribution.label unconditionally — an undefined contribution must
|
||||
// never reach it, otherwise the whole dock subtree crashes during render.
|
||||
@@ -70,6 +101,7 @@ describe("PluginBottomDock workbench contribution guard", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.useConnectionStore.mockReturnValue({ connections: [], sidebarLayout: { groups: [], order: [] }, ensureConnected: vi.fn(() => Promise.resolve()) });
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
@@ -137,11 +169,76 @@ describe("PluginBottomDock workbench contribution guard", () => {
|
||||
expect(renderError).toBeUndefined();
|
||||
expect(mocks.invokePlugin).not.toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls.filter((call) => String(call[0]).includes("[DBX][plugin:dock]"))).toEqual([]);
|
||||
// Without options_action the command doesn't own the picker, so the
|
||||
// generic replay item stays visible.
|
||||
expect(root.querySelector("[data-plugin-dock-plus-menu]")?.textContent).toContain("pluginDock.newTerminal");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the current UI locale to the options_action fetch", async () => {
|
||||
// Sidecars need the UI language to localize launch-option labels; the host
|
||||
// used to call options_action with an empty payload.
|
||||
const plugin = installedPlugin("sample.panel");
|
||||
plugin.manifest.contributions!.push({
|
||||
type: "command",
|
||||
id: "sample.open",
|
||||
label: "Open sample",
|
||||
action: { type: "open-workbench", workbench: "sample.panel", presentation: "panel", options_action: "local/terminal/launch-options" },
|
||||
});
|
||||
mocks.listPlugins.mockResolvedValue([plugin]);
|
||||
mocks.invokePlugin.mockResolvedValue({ entries: [{ label: "Local terminal (auto-detect)", description: "Default shell: /bin/zsh", context: {} }] });
|
||||
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
|
||||
await mountDock();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mocks.invokePlugin).toHaveBeenCalledWith("io.dbx.sample", "local/terminal/launch-options", { locale: "en" });
|
||||
const menu = root.querySelector("[data-plugin-dock-plus-menu]");
|
||||
expect(menu?.textContent).toContain("Local terminal (auto-detect)");
|
||||
expect(menu?.textContent).toContain("Default shell: /bin/zsh");
|
||||
// The plugin owns the picker now: the generic replay item hides.
|
||||
expect(menu?.textContent).not.toContain("pluginDock.newTerminal");
|
||||
});
|
||||
|
||||
it("renders the + picker connection targets as sidebar-grouped rows and opens them on pick", async () => {
|
||||
const plugin = installedPlugin("sample.panel");
|
||||
plugin.manifest.contributions!.push(
|
||||
{ type: "connection-provider", id: "ssh", label: "SSH", database_type: "plugin", fields: [] },
|
||||
{
|
||||
type: "command",
|
||||
id: "sample.open",
|
||||
label: "Open sample",
|
||||
action: { type: "open-workbench", workbench: "sample.panel", presentation: "panel", connection_targets: true },
|
||||
},
|
||||
);
|
||||
mocks.listPlugins.mockResolvedValue([plugin]);
|
||||
mocks.useConnectionStore.mockReturnValue({
|
||||
connections: [{ id: "conn-1", name: "my-server", plugin_connection_provider: "ssh" }],
|
||||
sidebarLayout: {
|
||||
groups: [{ id: "g1", name: "Prod", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "conn-1" }] }],
|
||||
},
|
||||
ensureConnected: vi.fn(() => Promise.resolve()),
|
||||
});
|
||||
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
|
||||
await mountDock();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const menu = root.querySelector("[data-plugin-dock-plus-menu]")!;
|
||||
expect(menu.textContent).toContain("Prod");
|
||||
expect(menu.textContent).toContain("my-server");
|
||||
// The flat "connection · name" prefix is gone; rows carry icons instead.
|
||||
expect(menu.textContent).not.toContain("pluginDock.connectionTerminal");
|
||||
|
||||
const row = [...menu.querySelectorAll("[data-dropdown-item-stub]")].find((element) => element.textContent?.includes("my-server")) as HTMLElement;
|
||||
row.click();
|
||||
const state = dock.usePluginBottomDock();
|
||||
const picked = state.entries.value[state.entries.value.length - 1]!;
|
||||
entryIds.push(picked.id);
|
||||
expect(picked).toMatchObject({ kind: "connection", context: { connectionId: "conn-1" } });
|
||||
});
|
||||
|
||||
it("degrades to an empty frame instead of crashing when the plugin no longer declares the entry's workbench contribution", async () => {
|
||||
// Plugin upgrade/downgrade replaced the workbench id the entry was opened with.
|
||||
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.renamed")]);
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
// Each entry owns a host-stable workbenchId; v-show keeps sessions alive while switching tabs.
|
||||
import { computed, nextTick, onScopeDispose, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ChevronDown, ChevronUp, Maximize2, Minimize2, Plus, X } from "@lucide/vue";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Folder, Maximize2, Minimize2, Plus, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
usePluginBottomDock,
|
||||
type PluginDockEntry,
|
||||
} from "@/lib/plugins/pluginBottomDock";
|
||||
import { buildConnectionPickerRows } from "@/lib/connection/connectionPickerTree";
|
||||
import { useDockResize } from "@/composables/useDockResize";
|
||||
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
@@ -36,7 +38,7 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
// Tab strip interactions (drag reorder + double-click rename).
|
||||
const dragEntryId = ref<string | null>(null);
|
||||
const renamingEntryId = ref<string | null>(null);
|
||||
@@ -179,7 +181,9 @@ async function loadLaunchOptions() {
|
||||
// options_action is OPTIONAL on open-workbench commands: without it there is
|
||||
// nothing to fetch — proceeding fired a doomed invokePlugin(undefined) IPC
|
||||
// round trip and a console.warn on every picker open / entry switch.
|
||||
const cacheKey = action?.options_action && pluginId && commandId ? `${pluginId}:${commandId}` : "";
|
||||
// The locale is part of the cache key: a UI language switch must refetch
|
||||
// instead of repainting labels cached under the previous language.
|
||||
const cacheKey = action?.options_action && pluginId && commandId ? `${pluginId}:${commandId}:${locale.value}` : "";
|
||||
if (!cacheKey) {
|
||||
launchOptionEntries.value = [];
|
||||
return;
|
||||
@@ -191,7 +195,10 @@ async function loadLaunchOptions() {
|
||||
launchOptionEntries.value = cached ?? [];
|
||||
launchOptionsLoading.value = !cached;
|
||||
try {
|
||||
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId!, action!.options_action!, {});
|
||||
// locale tells the sidecar which UI language to label the entries in —
|
||||
// the same field the plugin UI webview receives at init. Sidecars that
|
||||
// don't read it are unaffected (they ignore the extra param).
|
||||
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId!, action!.options_action!, { locale: locale.value });
|
||||
const next = (result?.entries ?? []).map((entry, index) => ({ key: `opt:${index}`, label: entry.label, description: entry.description, context: entry.context }));
|
||||
launchOptionsCache.set(cacheKey, next);
|
||||
// A stale response (entry switched while fetching) must not overwrite the
|
||||
@@ -385,28 +392,76 @@ const plusOpen = ref(false);
|
||||
// what the entries mean.
|
||||
const plusFilter = ref("");
|
||||
const PLUS_FILTER_THRESHOLD = 8;
|
||||
const plusItemCount = computed(() => 1 + launchOptionEntries.value.length + connectionTargets.value.length);
|
||||
// A command declaring options_action owns this picker: the generic replay
|
||||
// item hides (replaying a singleton command would only refocus the live
|
||||
// panel — multi-open is exactly what the plugin's own options exist for).
|
||||
const showReplayItem = computed(() => !activeAction.value?.options_action);
|
||||
const plusItemCount = computed(() => (showReplayItem.value ? 1 : 0) + launchOptionEntries.value.length + connectionTargets.value.length);
|
||||
const plusQuery = computed(() => plusFilter.value.trim().toLowerCase());
|
||||
function plusMatches(label: string): boolean {
|
||||
return !plusQuery.value || label.toLowerCase().includes(plusQuery.value);
|
||||
}
|
||||
const visibleLaunchOptions = computed(() => launchOptionEntries.value.filter((option) => plusMatches(option.label)));
|
||||
const visibleConnectionTargets = computed(() => connectionTargets.value.filter((target) => plusMatches(optionLabel(target))));
|
||||
function optionLabel(target: { label: string }): string {
|
||||
return target.label;
|
||||
// Connection rows reuse the AI assistant's connection tree select logic
|
||||
// (buildConnectionPickerRows): sidebar groups, per-connection icons, and a
|
||||
// search that also matches group paths and force-expands its matches — the
|
||||
// flat label filter below stays for the replay/launch-option items only.
|
||||
// Collapsed groups are dock-local view state.
|
||||
const collapsedPickerGroups = ref(new Set<string>());
|
||||
const connectionById = computed(() => new Map(connectionTargets.value.map((target) => [target.connection.id, target.connection])));
|
||||
const connectionPickerRows = computed(() =>
|
||||
buildConnectionPickerRows(
|
||||
connectionStore.sidebarLayout,
|
||||
connectionTargets.value.map((target) => target.connection),
|
||||
collapsedPickerGroups.value,
|
||||
plusQuery.value,
|
||||
),
|
||||
);
|
||||
function togglePickerGroup(groupId: string) {
|
||||
if (plusQuery.value) return;
|
||||
const next = new Set(collapsedPickerGroups.value);
|
||||
if (!next.delete(groupId)) next.add(groupId);
|
||||
collapsedPickerGroups.value = next;
|
||||
}
|
||||
function setPlusOpen(open: boolean) {
|
||||
plusOpen.value = open;
|
||||
const plusHasMatches = computed(() => (showReplayItem.value && plusMatches(t("pluginDock.newTerminal"))) || visibleLaunchOptions.value.length > 0 || connectionPickerRows.value.length > 0);
|
||||
async function setPlusOpen(open: boolean) {
|
||||
plusFilter.value = "";
|
||||
if (open) {
|
||||
void loadLaunchOptions();
|
||||
warmActivePluginPanelHtml();
|
||||
if (!open) {
|
||||
plusOpen.value = false;
|
||||
return;
|
||||
}
|
||||
// Collapsed dock: the strip hugs the window bottom edge, leaving no room
|
||||
// below the "+" for a downward picker. Restore the panel body first — the
|
||||
// picker is about to add a panel entry, which a collapsed frame would hide.
|
||||
if (collapsed.value) collapsed.value = false;
|
||||
await nextTick();
|
||||
measurePlusMenuHeight();
|
||||
plusOpen.value = true;
|
||||
void loadLaunchOptions();
|
||||
warmActivePluginPanelHtml();
|
||||
}
|
||||
function closePlusMenu() {
|
||||
plusOpen.value = false;
|
||||
}
|
||||
|
||||
// Keep the "+" picker opening strictly downward. reka's menu chain silently
|
||||
// drops the `side-flip` prop (MenuContentImpl forwards side/align/sticky/... to
|
||||
// PopperContent but not sideFlip), so the flip middleware is always live: any
|
||||
// bottom overflow makes the menu jump above the trigger. Instead of fighting
|
||||
// the middleware, cap the menu height to the space remaining under the trigger
|
||||
// — floating-ui then never sees a bottom overflow, so the menu can only open
|
||||
// downward, while cross-axis shift keeps it inside the window near the edges.
|
||||
// Must run before `plusOpen` flips true: the inline cap has to be bound when
|
||||
// the content mounts, or the first uncapped measurement would still flip it.
|
||||
const PLUS_MENU_MIN_HEIGHT_PX = 80;
|
||||
const plusMenuMaxHeight = ref<string>();
|
||||
function measurePlusMenuHeight() {
|
||||
const trigger = dockRoot.value?.querySelector<HTMLElement>("[data-plugin-dock-plus-trigger]");
|
||||
if (!trigger) return;
|
||||
const below = window.innerHeight - trigger.getBoundingClientRect().bottom - 8;
|
||||
plusMenuMaxHeight.value = `${Math.max(PLUS_MENU_MIN_HEIGHT_PX, below)}px`;
|
||||
}
|
||||
|
||||
// Hide the first-open html pipeline under the time the user spends reading the
|
||||
// "+" picker: the read/decode/inline of a multi-megabyte ui build runs while
|
||||
// the menu is open, so whichever panel the user picks mounts on a warm cache
|
||||
@@ -489,31 +544,55 @@ onScopeDispose(() => window.removeEventListener("blur", onPlusMenuWindowBlur));
|
||||
viewport edge instead of running off-screen. -->
|
||||
<DropdownMenu v-if="activeCommand" :open="plusOpen" @update:open="setPlusOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('pluginDock.newTerminal')" :aria-expanded="plusOpen">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('pluginDock.newTerminal')" :aria-expanded="plusOpen" data-plugin-dock-plus-trigger>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<!-- sideFlip=false pins the menu below the "+": the dock panel body
|
||||
below it is the intended scroll area, so flipping upward over the
|
||||
workbench (the default when the panel is short) reads as the menu
|
||||
"growing from the mouse". Cross-axis shift stays on, so the menu
|
||||
still hugs the window edge instead of overflowing it; height
|
||||
follows the popper's available space below the trigger. -->
|
||||
<DropdownMenuContent align="start" :side-offset="4" :side-flip="false" class="max-h-(--reka-dropdown-menu-content-available-height) w-64" data-plugin-dock-plus-menu>
|
||||
<!-- Always pinned below the "+" (see measurePlusMenuHeight): the inline
|
||||
max-height caps the menu to the space under the trigger, so the
|
||||
flip middleware never sees a bottom overflow. Cross-axis shift
|
||||
stays on, so the menu still hugs the window edge instead of
|
||||
overflowing it; the capped list scrolls internally. -->
|
||||
<DropdownMenuContent align="start" :side-offset="4" class="max-h-(--reka-dropdown-menu-content-available-height) w-64" :style="plusMenuMaxHeight ? { maxHeight: plusMenuMaxHeight } : undefined" data-plugin-dock-plus-menu>
|
||||
<!-- Generic list filter (appears only for long lists): the host filters
|
||||
by label without knowing what the entries mean. -->
|
||||
<input v-if="plusItemCount > PLUS_FILTER_THRESHOLD" v-model="plusFilter" class="mb-1 w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary/40" :placeholder="t('pluginDock.filter')" spellcheck="false" @keydown.stop />
|
||||
<DropdownMenuItem v-if="plusMatches(t('pluginDock.newTerminal'))" @select="onPlusAction('replay')">
|
||||
<DropdownMenuItem v-if="showReplayItem && plusMatches(t('pluginDock.newTerminal'))" @select="onPlusAction('replay')">
|
||||
<Plus class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t("pluginDock.newTerminal") }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm">{{ t("pluginDock.newTerminal") }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-for="option in visibleLaunchOptions" :key="option.key" @select="onPlusAction(option.key)">
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
<PluginIcon :plugin-id="activeEntry?.pluginId ?? ''" :icon="activeCommand?.icon" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm">{{ option.label }}</span>
|
||||
<span v-if="option.description" class="block truncate text-xs text-muted-foreground">{{ option.description }}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-for="target in visibleConnectionTargets" :key="target.key" :title="target.connection.name" @select="onPlusAction(target.key)">
|
||||
<span class="truncate">{{ t("pluginDock.connectionTerminal") }} · {{ target.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
<div v-if="plusQuery && !plusMatches(t('pluginDock.newTerminal')) && !visibleLaunchOptions.length && !visibleConnectionTargets.length" class="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<DropdownMenuSeparator v-if="visibleLaunchOptions.length && connectionPickerRows.length" />
|
||||
<template v-for="row in connectionPickerRows" :key="row.key">
|
||||
<!-- Group headers are plain rows, not menu items: clicking toggles
|
||||
collapse (a no-op while searching), and reka-ui keyboard
|
||||
navigation skips them — same contract as the AI assistant's
|
||||
connection tree select. -->
|
||||
<div
|
||||
v-if="row.kind === 'group'"
|
||||
:title="row.label"
|
||||
:aria-expanded="!row.collapsed"
|
||||
:style="{ paddingLeft: `${8 + row.depth * 14}px` }"
|
||||
class="flex h-6 cursor-default select-none items-center gap-1.5 rounded-md pr-2 text-xs font-medium text-muted-foreground"
|
||||
@click="togglePickerGroup(row.id)"
|
||||
>
|
||||
<ChevronDown v-if="!row.collapsed" class="h-3 w-3 shrink-0" />
|
||||
<ChevronRight v-else class="h-3 w-3 shrink-0" />
|
||||
<Folder class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ row.label }}</span>
|
||||
</div>
|
||||
<DropdownMenuItem v-else :title="row.label" :style="{ paddingLeft: `${8 + row.depth * 14}px` }" @select="onPlusAction(`conn:${row.id}`)">
|
||||
<ConnectionIcon :connection="connectionById.get(row.id)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm">{{ row.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<div v-if="plusQuery && !plusHasMatches" class="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{{ t("pluginDock.noMatch") }}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -438,7 +438,7 @@ describe("PluginConnectionFields", () => {
|
||||
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
|
||||
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
|
||||
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options", { locale: "en" });
|
||||
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
|
||||
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
@@ -28,7 +28,7 @@ const emit = defineEmits<{
|
||||
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const formValues = computed(() => props.modelValue);
|
||||
@@ -118,7 +118,9 @@ watch(
|
||||
const action = field.options_action;
|
||||
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
|
||||
dynamicOptionsRequested.value.add(field.key);
|
||||
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
|
||||
// locale lets the sidecar localize the returned option labels (same
|
||||
// field the plugin UI webview receives at init); older sidecars ignore it.
|
||||
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action, { locale: locale.value })
|
||||
.then((result) => {
|
||||
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
|
||||
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
|
||||
|
||||
@@ -16,7 +16,12 @@ vi.mock("vue-virtual-scroller", () => ({
|
||||
inheritAttrs: false,
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h("div", attrs, (props.items as unknown[]).flatMap((item) => slots.default?.({ item }) ?? []));
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
attrs,
|
||||
(props.items as unknown[]).flatMap((item) => slots.default?.({ item }) ?? []),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -305,14 +305,7 @@ defineExpose({ refresh: () => load(currentUri.value) });
|
||||
<div v-else-if="!sortedEntries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("pluginPlatform.emptyDirectory") }}</div>
|
||||
<RecycleScroller v-else data-file-list class="plugin-file-list min-h-0 min-w-0 flex-1 overflow-x-hidden text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
|
||||
<template #default="{ item: entry }">
|
||||
<button
|
||||
type="button"
|
||||
class="plugin-file-row grid h-[46px] w-full min-w-0 items-center gap-3 border-b px-3 text-left hover:bg-muted/50"
|
||||
:class="selected?.uri === entry.uri ? 'bg-muted' : ''"
|
||||
:data-file-entry-uri="entry.uri"
|
||||
@click="selected = entry"
|
||||
@dblclick="activateEntry(entry)"
|
||||
>
|
||||
<button type="button" class="plugin-file-row grid h-[46px] w-full min-w-0 items-center gap-3 border-b px-3 text-left hover:bg-muted/50" :class="selected?.uri === entry.uri ? 'bg-muted' : ''" :data-file-entry-uri="entry.uri" @click="selected = entry" @dblclick="activateEntry(entry)">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
|
||||
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
|
||||
|
||||
@@ -2026,7 +2026,7 @@ async function runRedisCommand(command: string) {
|
||||
const entryId = appendCommandHistory({ prompt, command, output: "", error: false });
|
||||
try {
|
||||
const result = await api.redisExecuteCommand(props.connectionId, commandDb.value, command, !props.blockDangerousRedisCommands);
|
||||
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value), error: false });
|
||||
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value, command), error: false });
|
||||
// The db this command ran on — capture before nextRedisCommandDb() advances it.
|
||||
const executedDb = commandDb.value;
|
||||
commandDb.value = nextRedisCommandDb(commandDb.value, command, result.value);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import RedisQueryConsoleOutput from "@/components/redis/RedisQueryConsoleOutput.vue";
|
||||
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mounted.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("RedisQueryConsoleOutput", () => {
|
||||
it("renders every command result and keeps failures visually distinct", async () => {
|
||||
const results: QueryResult[] = [
|
||||
{ ...redisCommandResultToQueryResult("PONG", 1, "PING"), sourceStatement: "PING" },
|
||||
{ ...redisCommandResultToQueryResult(["one", "two"], 1, "LRANGE items 0 -1"), sourceStatement: "LRANGE items 0 -1" },
|
||||
{ columns: ["Error"], rows: [["ERR unknown command"]], affected_rows: 0, execution_time_ms: 0, execution_error: true, sourceStatement: "NOPE" },
|
||||
];
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp({ render: () => h(RedisQueryConsoleOutput, { results }) });
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { redis: { commandLine: "Command line", commandEmptyResult: "No output" } } } }));
|
||||
app.mount(host);
|
||||
mounted.push({ unmount: () => app.unmount(), host });
|
||||
await nextTick();
|
||||
|
||||
expect([...host.querySelectorAll("div.flex.min-w-0")].map((node) => node.textContent?.trim())).toEqual(["redis>PING", "redis>LRANGE items 0 -1", "redis>NOPE"]);
|
||||
expect([...host.querySelectorAll("pre")].map((node) => node.textContent)).toEqual(["PONG", '1) "one"\n2) "two"', "(error) ERR unknown command"]);
|
||||
expect(host.querySelectorAll("pre")[2]?.className).toContain("text-[#ff6b6b]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Loader2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { redisQueryResultsToConsoleEntries } from "@/lib/redis/redisQueryResult";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
result?: QueryResult;
|
||||
results?: QueryResult[];
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{ loading: false },
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const entries = computed(() => redisQueryResultsToConsoleEntries(props.results?.length ? props.results : props.result ? [props.result] : []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-redis-query-console role="region" class="dbx-editor-font-family flex min-h-0 flex-1 flex-col bg-[#171b21] text-[13px] leading-5 text-slate-200" :aria-label="t('redis.commandLine')">
|
||||
<div class="min-h-0 flex-1 overflow-auto px-4 py-4">
|
||||
<div v-for="(entry, index) in entries" :key="`${index}:${entry.command}`" class="mb-4 last:mb-0">
|
||||
<div v-if="entry.command" class="flex min-w-0 items-start gap-2 whitespace-pre-wrap break-words">
|
||||
<span class="shrink-0 text-[#d7ba7d]">redis></span>
|
||||
<span class="min-w-0 text-slate-200">{{ entry.command }}</span>
|
||||
</div>
|
||||
<pre data-native-clipboard class="m-0 whitespace-pre-wrap break-words" :class="entry.error ? 'text-[#ff6b6b]' : 'text-slate-300'">{{ entry.output }}</pre>
|
||||
</div>
|
||||
<div v-if="loading && entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
|
||||
{{ t("redis.commandEmptyResult") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -87,7 +87,18 @@ import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTr
|
||||
import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu";
|
||||
import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter";
|
||||
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
|
||||
import { hasTableVGroupEntries, isTableVGroupContainerNode, isTableVGroupGroupableRowType, resolveTableVGroupScopeFromNode, selectedTableVGroupMoveTargets, tableVGroupDestinationRows, tableVGroupPathForTable, tableVGroupScopeKey, tableVGroupsEnabled } from "@/lib/table/tableVGroup";
|
||||
import {
|
||||
hasTableTreeLoadMore,
|
||||
hasTableVGroupEntries,
|
||||
isTableVGroupContainerNode,
|
||||
isTableVGroupGroupableRowType,
|
||||
resolveTableVGroupScopeFromNode,
|
||||
selectedTableVGroupMoveTargets,
|
||||
tableVGroupDestinationRows,
|
||||
tableVGroupPathForTable,
|
||||
tableVGroupScopeKey,
|
||||
tableVGroupsEnabled,
|
||||
} from "@/lib/table/tableVGroup";
|
||||
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
|
||||
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
|
||||
import { requestObjectBrowserSearchFocus } from "@/lib/tabs/objectBrowserSearchFocus";
|
||||
@@ -883,7 +894,8 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
// particular, schema-level trigger/type groups have no tableName, so they
|
||||
// must use the generic object loader rather than the table-trigger loader.
|
||||
const databaseObjectGroup = !!objectTypesForGroupNode(node.type);
|
||||
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node)) {
|
||||
const needsConfiguredTablePageDrain = node.type === "group-tables" && connectionStore.getConfig(node.connectionId || "")?.sidebar_auto_load_all_tables === true && hasTableTreeLoadMore(node.children ?? []);
|
||||
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node) && !needsConfiguredTablePageDrain) {
|
||||
node.isExpanded = !node.isExpanded;
|
||||
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
|
||||
emitNodeToggled(node, wasExpanded);
|
||||
@@ -5480,7 +5492,10 @@ function exportDataSubmenu(includeSqlInsert = true): ContextMenuItem {
|
||||
{ label: "CSV", action: () => exportData("csv") },
|
||||
{ label: "JSON", action: () => exportData("json") },
|
||||
];
|
||||
if (includeSqlInsert) children.push({ label: "SQL INSERT", action: () => exportData("sql") });
|
||||
if (includeSqlInsert) {
|
||||
children.push({ label: "SQL INSERT", action: () => exportData("sql", "source") });
|
||||
children.push({ label: t("contextMenu.standardSqlInsert"), action: () => exportData("sql", "standard") });
|
||||
}
|
||||
children.push({ label: "XLSX", action: () => exportDataXlsx() });
|
||||
return {
|
||||
label: count > 1 ? t("contextMenu.exportDataMultiple", { count }) : t("contextMenu.exportData"),
|
||||
|
||||
+36
@@ -259,6 +259,42 @@ describe("SidebarTreeRuntimeHost expansion", () => {
|
||||
expect(connectionStore.loadObjectGroupChildren).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes an opted-in partial table group back through the loader", async () => {
|
||||
const group: TreeNode = {
|
||||
id: "mysql:basic:__tables",
|
||||
label: "tree.tables",
|
||||
type: "group-tables",
|
||||
connectionId: "mysql",
|
||||
database: "basic",
|
||||
isExpanded: false,
|
||||
children: [
|
||||
{ id: "mysql:basic:orders", label: "orders", type: "table", connectionId: "mysql", database: "basic" },
|
||||
{
|
||||
id: "mysql:basic:__tables:__load_more:1000",
|
||||
label: "tree.loadMore",
|
||||
type: "load-more",
|
||||
connectionId: "mysql",
|
||||
database: "basic",
|
||||
loadMore: { parentId: "mysql:basic:__tables", offset: 1000, pageSize: 1000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection", sidebar_auto_load_all_tables: true });
|
||||
|
||||
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
|
||||
const app = createApp(defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 }) }));
|
||||
mountedApps.push(app);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
|
||||
host.value?.toggleNode(group);
|
||||
|
||||
await vi.waitFor(() => expect(connectionStore.loadObjectGroupChildren).toHaveBeenCalledWith(group, undefined));
|
||||
expect(group.isExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("publishes a rendered group collapse and synchronizes the live tree", async () => {
|
||||
const liveGroup: TreeNode = {
|
||||
id: "connection:database:__tables",
|
||||
|
||||
@@ -23,6 +23,8 @@ const mocks = vi.hoisted(() => ({
|
||||
inspectSqlFileTables: vi.fn(),
|
||||
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
|
||||
changeDialogOpen: undefined as undefined | ((open: boolean) => void),
|
||||
connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }] as Array<{ id: string; name: string; db_type: string; driver_profile: string; database: string }>,
|
||||
getConfig: vi.fn(),
|
||||
refreshDatabaseTreeNode: vi.fn(),
|
||||
requestConfirmation: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
@@ -30,6 +32,10 @@ const mocks = vi.hoisted(() => ({
|
||||
unlisten: vi.fn(),
|
||||
updateSqlFileTask: vi.fn(),
|
||||
uuid: vi.fn(),
|
||||
queryStore: {
|
||||
tabs: [{ id: "mysql-tab", connectionId: "mysql-1", database: "" }],
|
||||
activeTabId: "mysql-tab" as string | null,
|
||||
},
|
||||
}));
|
||||
|
||||
function passthrough(tag: string) {
|
||||
@@ -65,12 +71,15 @@ vi.mock("@/stores/productionSafetyStore", () => ({
|
||||
}));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql" }],
|
||||
get connections() {
|
||||
return mocks.connections;
|
||||
},
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
getConfig: () => ({ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }),
|
||||
getConfig: mocks.getConfig,
|
||||
refreshDatabaseTreeNode: mocks.refreshDatabaseTreeNode,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => mocks.queryStore }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
beginManualTransaction: mocks.beginManualTransaction,
|
||||
commitManualTransaction: mocks.commitManualTransaction,
|
||||
@@ -126,6 +135,7 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: passthrough("sp
|
||||
vi.mock("@/components/connection/ConnectionGroupBadge.vue", () => ({ default: passthrough("span") }));
|
||||
|
||||
import SqlFileExecutionDialog from "./SqlFileExecutionDialog.vue";
|
||||
import { rememberExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
|
||||
let app: ReturnType<typeof createApp> | undefined;
|
||||
let root: HTMLDivElement | undefined;
|
||||
@@ -187,6 +197,7 @@ function deferred() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mocks.desktop = true;
|
||||
mocks.releaseSqlFilePreview.mockResolvedValue(undefined);
|
||||
mocks.progressHandler = undefined;
|
||||
@@ -230,6 +241,8 @@ beforeEach(() => {
|
||||
{ database: "archive", name: "users" },
|
||||
]);
|
||||
mocks.fetchSqlFileTargetOptions.mockResolvedValue([]);
|
||||
mocks.connections = [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }];
|
||||
mocks.getConfig.mockImplementation((connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId));
|
||||
mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]);
|
||||
mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({
|
||||
fileName: filePath.split("/").pop()!,
|
||||
@@ -246,6 +259,8 @@ beforeEach(() => {
|
||||
mocks.refreshDatabaseTreeNode.mockResolvedValue(undefined);
|
||||
mocks.requestConfirmation.mockResolvedValue(true);
|
||||
mocks.uuid.mockReset().mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
|
||||
mocks.queryStore.tabs = [{ id: "mysql-tab", connectionId: "mysql-1", database: "" }];
|
||||
mocks.queryStore.activeTabId = "mysql-tab";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -604,6 +619,61 @@ describe("SqlFileExecutionDialog retries", () => {
|
||||
expect(findButton("sqlFile.execute").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the active SQL tab instead of the first connection when opened from the toolbar", async () => {
|
||||
mocks.connections = [
|
||||
{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "first" },
|
||||
{ id: "postgres-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "analytics" },
|
||||
];
|
||||
mocks.queryStore.tabs = [{ id: "postgres-tab", connectionId: "postgres-1", database: "reporting" }];
|
||||
mocks.queryStore.activeTabId = "postgres-tab";
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(SqlFileExecutionDialog, { open: true });
|
||||
app.mount(root);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.ensureConnected).toHaveBeenCalledWith("postgres-1"));
|
||||
expect(mocks.ensureConnected).not.toHaveBeenCalledWith("mysql-1");
|
||||
});
|
||||
|
||||
it("does not select a connection when the active tab is non-SQL", async () => {
|
||||
mocks.connections = [
|
||||
{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "first" },
|
||||
{ id: "kafka-1", name: "Kafka", db_type: "mq", driver_profile: "kafka", database: "invalid" },
|
||||
];
|
||||
mocks.queryStore.tabs = [{ id: "kafka-tab", connectionId: "kafka-1", database: "invalid" }];
|
||||
mocks.queryStore.activeTabId = "kafka-tab";
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(SqlFileExecutionDialog, { open: true });
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.ensureConnected).not.toHaveBeenCalled();
|
||||
expect(mocks.fetchSqlFileTargetOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a remembered file target override the active toolbar target", async () => {
|
||||
mocks.connections = [
|
||||
{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "app" },
|
||||
{ id: "saved-1", name: "Saved", db_type: "postgres", driver_profile: "postgres", database: "saved_db" },
|
||||
];
|
||||
rememberExternalSqlFileTarget("/tmp/saved.sql", { connectionId: "saved-1", database: "saved_db" });
|
||||
mocks.openFileDialog.mockResolvedValueOnce("/tmp/saved.sql");
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(SqlFileExecutionDialog, { open: true });
|
||||
app.mount(root);
|
||||
await vi.waitFor(() => expect(mocks.ensureConnected).toHaveBeenCalledWith("mysql-1"));
|
||||
mocks.ensureConnected.mockClear();
|
||||
|
||||
findButton("sqlFile.browse").click();
|
||||
|
||||
await vi.waitFor(() => expect(mocks.ensureConnected).toHaveBeenCalledWith("saved-1"));
|
||||
});
|
||||
|
||||
it("does not restore a completed run's file summary after an early retry failure", async () => {
|
||||
await mountReadyDialog();
|
||||
await completeFirstExecution();
|
||||
|
||||
@@ -14,14 +14,17 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import { supportsSqlFileExecution, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
|
||||
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
|
||||
import { requiresSqlFileTargetDatabaseSelection, supportsConnectionLevelDatabaseBootstrap } from "@/lib/connection/connectionLevelDatabaseBootstrap";
|
||||
import { beginManualTransaction, commitManualTransaction, rollbackManualTransaction, cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
|
||||
import { activeTabExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { isSqlFilePath } from "@/lib/sql/sqlFileOpen";
|
||||
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
|
||||
import { parseSqlFilePathInput } from "./sqlFilePathInput";
|
||||
import SqlFileProgressIndicator from "./SqlFileProgressIndicator.vue";
|
||||
@@ -43,6 +46,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const store = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
// Tauri = real filesystem paths; Web = browser File.name (no path) + server temp paths.
|
||||
const isDesktopRuntime = isTauriRuntime();
|
||||
@@ -92,7 +96,7 @@ async function commitPathInput() {
|
||||
return;
|
||||
}
|
||||
const typed = pathInput.value;
|
||||
await loadPreviews(paths);
|
||||
await loadPreviews(paths, true);
|
||||
// Failed load: keep what was typed so the path can be corrected.
|
||||
pathInput.value = previews.value.length > 0 ? filePathDisplay.value : typed;
|
||||
}
|
||||
@@ -116,6 +120,7 @@ const connectionId = ref("");
|
||||
const database = ref("");
|
||||
const databaseOptions = ref<string[]>([]);
|
||||
const loadingDatabases = ref(false);
|
||||
const preferredTarget = ref<ExternalSqlFileTarget>();
|
||||
const continueOnError = ref(false);
|
||||
const skipRelationalConstraints = ref(false);
|
||||
const manualTransaction = ref(false);
|
||||
@@ -169,7 +174,7 @@ function resetPerFileState() {
|
||||
currentFileName.value = "";
|
||||
}
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => supportsSqlFileExecution(connection.db_type)));
|
||||
// Mirrors the core executor gate (`relational_constraint_bypass_kind` in
|
||||
// sql_file_import.rs): MySQL-family types use the session-scoped
|
||||
// FOREIGN_KEY_CHECKS toggle, PostgreSQL-family types use DISABLE/ENABLE
|
||||
@@ -376,22 +381,29 @@ function statusLabel(status: SqlFileStatus | "idle") {
|
||||
return t(`sqlFile.status.${status}`);
|
||||
}
|
||||
|
||||
function resolveInitialConnectionId() {
|
||||
function resolveInitialTarget(): ExternalSqlFileTarget {
|
||||
if (props.prefillConnectionId && sqlConnections.value.some((c) => c.id === props.prefillConnectionId)) {
|
||||
return props.prefillConnectionId;
|
||||
return { connectionId: props.prefillConnectionId, database: props.prefillDatabase ?? "" };
|
||||
}
|
||||
if (props.prefillFilePath) return "";
|
||||
return sqlConnections.value[0]?.id ?? "";
|
||||
if (props.prefillFilePath) return { connectionId: "", database: "" };
|
||||
return activeTabExternalSqlFileTarget(queryStore.tabs, queryStore.activeTabId, (connectionId) => store.getConfig(connectionId));
|
||||
}
|
||||
|
||||
function applyTarget(target: ExternalSqlFileTarget) {
|
||||
preferredTarget.value = target;
|
||||
connectionId.value = target.connectionId;
|
||||
database.value = target.database;
|
||||
}
|
||||
|
||||
function chooseDatabase(names: string[], id: string) {
|
||||
const configDatabase = store.getConfig(id)?.database ?? "";
|
||||
const preferredDatabase = preferredTarget.value?.connectionId === id ? preferredTarget.value.database : props.prefillDatabase;
|
||||
if (names.length > 0) {
|
||||
if (props.prefillDatabase && names.includes(props.prefillDatabase)) return props.prefillDatabase;
|
||||
if (preferredDatabase && names.includes(preferredDatabase)) return preferredDatabase;
|
||||
if (configDatabase && names.includes(configDatabase)) return configDatabase;
|
||||
return names.length === 1 ? names[0] : "";
|
||||
}
|
||||
return props.prefillDatabase ?? configDatabase;
|
||||
return preferredDatabase ?? configDatabase;
|
||||
}
|
||||
|
||||
function resetExecution() {
|
||||
@@ -410,11 +422,11 @@ function resetExecution() {
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
const initialTarget = resolveInitialTarget();
|
||||
previews.value = [];
|
||||
selectingFile.value = false;
|
||||
loadingPreview.value = false;
|
||||
connectionId.value = resolveInitialConnectionId();
|
||||
database.value = "";
|
||||
applyTarget(initialTarget);
|
||||
databaseOptions.value = [];
|
||||
loadingDatabases.value = false;
|
||||
continueOnError.value = false;
|
||||
@@ -469,7 +481,7 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
|
||||
return previewWebSqlFile(file);
|
||||
}
|
||||
|
||||
async function loadPreviews(filesOrPaths: Array<string | File>) {
|
||||
async function loadPreviews(filesOrPaths: Array<string | File>, resolveSelectedFileTarget = false) {
|
||||
if (executionLocked.value) return;
|
||||
await releaseManagedPreviews();
|
||||
loadingPreview.value = true;
|
||||
@@ -481,6 +493,11 @@ async function loadPreviews(filesOrPaths: Array<string | File>) {
|
||||
nextPreviews.push(await previewSelectedSqlFile(fileOrPath));
|
||||
}
|
||||
previews.value = nextPreviews;
|
||||
if (resolveSelectedFileTarget) {
|
||||
const firstPath = typeof filesOrPaths[0] === "string" && isSqlFilePath(filesOrPaths[0]) ? filesOrPaths[0] : undefined;
|
||||
const target = firstPath ? resolveExternalSqlFileTargetForActiveTab(firstPath, queryStore.tabs, queryStore.activeTabId, (connectionId) => store.getConfig(connectionId)) : activeTabExternalSqlFileTarget(queryStore.tabs, queryStore.activeTabId, (connectionId) => store.getConfig(connectionId));
|
||||
applyTarget(target);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
@@ -515,7 +532,7 @@ async function selectFile() {
|
||||
});
|
||||
const paths = Array.isArray(selected) ? selected : selected ? [selected] : [];
|
||||
if (paths.length > 0) {
|
||||
await loadPreviews(paths);
|
||||
await loadPreviews(paths, true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
@@ -531,7 +548,7 @@ async function handleFileInputChange(event: Event) {
|
||||
if (files.length === 0 || executionLocked.value) return;
|
||||
selectingFile.value = true;
|
||||
try {
|
||||
await loadPreviews(files);
|
||||
await loadPreviews(files, true);
|
||||
} finally {
|
||||
selectingFile.value = false;
|
||||
}
|
||||
@@ -772,7 +789,7 @@ watch(connectionId, (id) => {
|
||||
|
||||
watch(sqlConnections, () => {
|
||||
if (!open.value || executionLocked.value || selectedConnection.value) return;
|
||||
connectionId.value = resolveInitialConnectionId();
|
||||
applyTarget(resolveInitialTarget());
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -6637,11 +6637,6 @@ watch(
|
||||
inset 0 1px 0 color-mix(in oklab, var(--primary) 55%, transparent),
|
||||
inset 0 -1px 0 color-mix(in oklab, var(--primary) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Inputs are bg-transparent; give them a solid surface on the selected row so fields stay readable. */
|
||||
.structure-column-search-current :is(input, button, [role="combobox"], [data-slot="select-trigger"]) {
|
||||
background-color: var(--background);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../TableStructureEditor.vue", import.meta.url), "utf8");
|
||||
const scopedStyleStart = source.indexOf("<style scoped>");
|
||||
const scopedStyleEnd = source.indexOf("</style>", scopedStyleStart);
|
||||
const scopedStyle = source.slice(scopedStyleStart, scopedStyleEnd).replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const rules = [...scopedStyle.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((match) => ({
|
||||
selectors: match[1]!.split(",").map((selector) => selector.trim()),
|
||||
declarations: match[2]!,
|
||||
}));
|
||||
|
||||
describe("TableStructureEditor selected-row styles", () => {
|
||||
it("keeps the highlight on cells without repainting descendant controls", () => {
|
||||
const selectedBackgroundRules = rules.filter(({ selectors, declarations }) => selectors.some((selector) => selector.includes(".structure-column-search-current")) && /background(?:-color)?\s*:/.test(declarations));
|
||||
|
||||
expect(selectedBackgroundRules).toHaveLength(1);
|
||||
expect(selectedBackgroundRules[0]?.selectors).toEqual([".structure-column-search-current > td"]);
|
||||
expect(selectedBackgroundRules[0]?.declarations).toContain("var(--primary)");
|
||||
|
||||
const gridControlRule = rules.find(({ selectors }) => selectors.includes(".structure-edit-grid :deep(.structure-grid-control)"));
|
||||
expect(gridControlRule?.declarations).toContain("background-color: transparent;");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildTransferObjectSelections } from "./transferSelections";
|
||||
import { buildTransferObjectSelections, countTransferObjects } from "./transferSelections";
|
||||
import { createTaskLoadTracker } from "./taskLoadTracker";
|
||||
import { confirmTransferWithProductionSafety, createTransferSubmission, rebuildUnavailableReason, resolveTransferStrategy, transferStrategyOptions, type TransferStrategy } from "./transferStrategy";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -21,6 +21,7 @@ import type { TransferContent, TransferObjectKind, TransferTableNameCase } from
|
||||
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
|
||||
import ObjectSelectionTree from "@/components/transfer/ObjectSelectionTree.vue";
|
||||
import TransferTaskTree from "@/components/transfer/TransferTaskTree.vue";
|
||||
import DataTransferProgressDialog from "@/components/transfer/DataTransferProgressDialog.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { TransferTask, TransferTaskConfig } from "@/types/database";
|
||||
@@ -31,13 +32,14 @@ import { decodeTransferDatabaseOption, encodeTransferDatabaseOptions, formatTran
|
||||
import { formatDatabaseLabel } from "@/lib/database/defaultDatabase";
|
||||
import { databaseOptionsForConnection, fetchCatalogNamespaceOptions, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
import { openDataTransferTask } from "@/composables/useDialogSources";
|
||||
import { useTransferTaskStore, TransferTaskNameConflictError, nextTransferTaskCopyName } from "@/stores/transferTaskStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import type { CatalogInfo } from "@/types/database";
|
||||
import { ArrowRightLeft, ArrowLeftRight, Loader2 } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { startDataTransferTask } = useExportTracker();
|
||||
const { tasks, startDataTransferTask } = useExportTracker();
|
||||
const { toast } = useToast();
|
||||
const taskStore = useTransferTaskStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
@@ -52,8 +54,14 @@ const props = defineProps<{
|
||||
prefillTargetConnectionId?: string;
|
||||
prefillTargetDatabase?: string;
|
||||
prefillTargetSchema?: string;
|
||||
taskId?: string | null;
|
||||
}>();
|
||||
|
||||
const trackedTransferTask = computed(() => {
|
||||
if (!props.taskId) return undefined;
|
||||
return tasks.value.find((task) => task.exportId === props.taskId && task.kind === "data-transfer");
|
||||
});
|
||||
|
||||
const transferDialogStyle = {
|
||||
width: "min(1120px, calc(100vw - 2rem))",
|
||||
height: "min(80vh, calc(var(--dbx-viewport-height) - 2rem))",
|
||||
@@ -609,9 +617,9 @@ watch(targetDatabase, async (db) => {
|
||||
});
|
||||
|
||||
watch(
|
||||
open,
|
||||
async (val) => {
|
||||
if (val) {
|
||||
[open, () => props.taskId],
|
||||
async ([val, taskId]) => {
|
||||
if (val && !taskId) {
|
||||
void taskStore.initFromStorage();
|
||||
resetState();
|
||||
pendingSourceSchemaPrefill.value = props.prefillSchema ?? "";
|
||||
@@ -815,6 +823,8 @@ function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: bool
|
||||
isSubmitting.value = true;
|
||||
startDataTransferTask(request, `${request.sourceDatabase} → ${request.targetDatabase}`, {
|
||||
formatOverlapError: (tables) => t("transfer.targetTableBusy", { tables: tables.join(", ") }),
|
||||
onStarted: () => toast(t("transfer.backgroundStarted")),
|
||||
onOpen: () => openDataTransferTask(request.transferId),
|
||||
onDone: async () => {
|
||||
if (shouldRefreshTargetTree) {
|
||||
await store.refreshObjectListTreeNode(request.targetConnectionId, request.targetDatabase, request.targetSchema, request.targetCatalog);
|
||||
@@ -1052,7 +1062,7 @@ const confirmationSummary = computed(() => {
|
||||
if (!request) return "";
|
||||
const source = formatTransferEndpointLabel(getConnectionName(request.sourceConnectionId), request.sourceDatabase, request.sourceSchema, request.sourceCatalog);
|
||||
const target = formatTransferEndpointLabel(getConnectionName(request.targetConnectionId), request.targetDatabase, request.targetSchema, request.targetCatalog);
|
||||
const count = request.tables.length + request.objects.reduce((total, selection) => total + selection.names.length, 0);
|
||||
const count = countTransferObjects(request);
|
||||
return t("transfer.startConfirmMessage", { source, target, count });
|
||||
});
|
||||
|
||||
@@ -1154,7 +1164,8 @@ async function saveConfigTask() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DataTransferProgressDialog v-if="trackedTransferTask" v-model:open="open" :task="trackedTransferTask" />
|
||||
<Dialog v-else v-model:open="open">
|
||||
<DialogContent class="dbx-transfer-dialog sm:max-w-[1120px] max-h-[80vh] flex flex-col overflow-hidden resize" :style="transferDialogStyle" @interact-outside.prevent>
|
||||
<DialogHeader class="shrink-0">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertCircle, ArrowRightLeft, CheckCircle2, Loader2, Minimize2, X, XCircle } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const props = defineProps<{ task: ExportTask }>();
|
||||
const { cancelTask } = useExportTracker();
|
||||
const currentTime = ref(Date.now());
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const isActive = computed(() => ["Running", "Writing", "Cancelling"].includes(props.task.status));
|
||||
const progressPercent = computed(() => {
|
||||
if (props.task.status === "Done") return 100;
|
||||
if (!props.task.totalTables) return 0;
|
||||
return Math.min(95, Math.round(((props.task.tableIndex ?? 0) / props.task.totalTables) * 100));
|
||||
});
|
||||
const elapsed = computed(() => {
|
||||
const startedAt = props.task.startedAt ?? currentTime.value;
|
||||
return formatDataTransferDuration((props.task.finishedAt ?? currentTime.value) - startedAt);
|
||||
});
|
||||
const statusText = computed(() => {
|
||||
if (props.task.status === "Cancelling") return t("databaseBackup.cancelling");
|
||||
if (props.task.status === "Done") return t("exportProgress.compareDone");
|
||||
if (props.task.status === "Error") return t("exportProgress.compareError");
|
||||
if (props.task.status === "Cancelled") return t("exportProgress.compareCancelled");
|
||||
return t("exportProgress.compareRunning");
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
elapsedTimer = setInterval(() => {
|
||||
currentTime.value = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (elapsedTimer) clearInterval(elapsedTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="open = $event">
|
||||
<DialogContent class="sm:max-w-lg" data-transfer-progress-dialog @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<ArrowRightLeft class="h-4 w-4" />
|
||||
{{ t("exportProgress.dataTransferTitle", { name: task.tableName }) }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Loader2 v-if="isActive" class="h-4 w-4 animate-spin text-primary" />
|
||||
<CheckCircle2 v-else-if="task.status === 'Done'" class="h-4 w-4 text-green-500" />
|
||||
<XCircle v-else-if="task.status === 'Error'" class="h-4 w-4 text-destructive" />
|
||||
<AlertCircle v-else class="h-4 w-4 text-yellow-500" />
|
||||
<span>{{ statusText }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div v-if="task.totalTables" class="h-full rounded-full transition-[width] duration-300" :class="task.status === 'Done' ? 'bg-green-500' : 'bg-primary'" :style="{ width: `${progressPercent}%` }" />
|
||||
<div v-else-if="isActive" class="export-progress-indeterminate h-full rounded-full bg-primary" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span class="truncate">{{ task.currentTable || task.tableName }}</span>
|
||||
<span class="shrink-0 tabular-nums">{{ task.tableIndex ?? 0 }} / {{ task.totalTables ?? 0 }} · {{ task.rowsExported.toLocaleString() }} {{ t("exportProgress.rowsShort") }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("exportProgress.elapsed", { duration: elapsed }) }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="task.errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{{ translateBackendError(t, task.errorMessage) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<template v-if="isActive">
|
||||
<Button variant="ghost" size="sm" @click="open = false">
|
||||
<Minimize2 class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("exportProgress.minimize") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" :disabled="task.status === 'Cancelling'" @click="cancelTask(task.exportId)">
|
||||
<Loader2 v-if="task.status === 'Cancelling'" class="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<X v-else class="mr-1 h-3.5 w-3.5" />
|
||||
{{ task.status === "Cancelling" ? t("databaseBackup.cancelling") : t("exportProgress.cancel") }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-else variant="outline" size="sm" @click="open = false">{{ t("exportProgress.close") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-progress-indeterminate {
|
||||
width: 42%;
|
||||
animation: export-progress-slide 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes export-progress-slide {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(70%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cancelTransfer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
cancelDatabaseExport: vi.fn(),
|
||||
cancelSqlFileExecution: vi.fn(),
|
||||
cancelTableExport: vi.fn(),
|
||||
cancelTransfer: mocks.cancelTransfer,
|
||||
startTransfer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
return { Dialog: passthrough, DialogContent: passthrough, DialogFooter: passthrough, DialogHeader: passthrough, DialogTitle: passthrough };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("button", slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import DataTransferProgressDialog from "@/components/transfer/DataTransferProgressDialog.vue";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function resetTracker() {
|
||||
const tracker = useExportTracker();
|
||||
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetTracker();
|
||||
i18n.global.locale.value = "en";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
resetTracker();
|
||||
});
|
||||
|
||||
describe("DataTransferProgressDialog", () => {
|
||||
it("minimizes without cancelling and keeps the explicit cancel action", async () => {
|
||||
const tracker = useExportTracker();
|
||||
const task = tracker.addDataTransferTask("transfer-progress", "source → target", 2);
|
||||
let dialogOpen!: Ref<boolean>;
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
dialogOpen = ref(true);
|
||||
return () => h(DataTransferProgressDialog, { open: dialogOpen.value, "onUpdate:open": (value: boolean) => (dialogOpen.value = value), task });
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
const buttons = () => [...document.body.querySelectorAll<HTMLButtonElement>("button")];
|
||||
buttons()
|
||||
.find((button) => button.textContent?.includes("Minimize to background"))
|
||||
?.click();
|
||||
await nextTick();
|
||||
expect(dialogOpen.value).toBe(false);
|
||||
expect(mocks.cancelTransfer).not.toHaveBeenCalled();
|
||||
expect(task.status).toBe("Running");
|
||||
|
||||
dialogOpen.value = true;
|
||||
await nextTick();
|
||||
buttons()
|
||||
.find((button) => button.textContent?.trim() === "Cancel")
|
||||
?.click();
|
||||
await vi.waitFor(() => expect(mocks.cancelTransfer).toHaveBeenCalledWith(task.exportId));
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTransferObjectSelections, matchBulkObjectNames, parseBulkObjectNames } from "../transferSelections";
|
||||
import { buildTransferObjectSelections, countTransferObjects, matchBulkObjectNames, parseBulkObjectNames } from "../transferSelections";
|
||||
|
||||
function setOf(names: string[]): Set<string> {
|
||||
return new Set(names);
|
||||
}
|
||||
|
||||
describe("countTransferObjects", () => {
|
||||
it("counts selected tables and non-table objects from their separate request fields", () => {
|
||||
expect(
|
||||
countTransferObjects({
|
||||
tables: ["orders", "users"],
|
||||
objects: [
|
||||
{ objectType: "VIEW", names: ["active_users"] },
|
||||
{ objectType: "SEQUENCE", names: ["order_id_seq"] },
|
||||
],
|
||||
}),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("keeps a table-only multi-selection nonzero", () => {
|
||||
expect(countTransferObjects({ tables: ["orders", "users"], objects: [] })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTransferObjectSelections", () => {
|
||||
it("serializes non-table selections in request order", () => {
|
||||
const result = buildTransferObjectSelections(
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { TransferObjectKind } from "@/lib/backend/api";
|
||||
import type { TransferObjectKind, TransferRequest } from "@/lib/backend/api";
|
||||
|
||||
export interface TransferObjectSelectionPayload {
|
||||
objectType: TransferObjectKind;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
/** Counts table and non-table selections represented by a transfer request. */
|
||||
export function countTransferObjects(request: Pick<TransferRequest, "tables" | "objects">): number {
|
||||
return request.tables.length + request.objects.reduce((total, selection) => total + selection.names.length, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `objects` payload of a transfer request from the tree
|
||||
* selection state. TABLE is handled by `tables`, and object types that
|
||||
|
||||
@@ -43,6 +43,9 @@ const labels = computed(() => ({
|
||||
saved: t("zookeeper.saved"),
|
||||
deleted: t("zookeeper.deleted"),
|
||||
base64Readonly: t("zookeeper.base64Readonly"),
|
||||
clone: t("zookeeper.clone"),
|
||||
copyKey: t("zookeeper.copyKey"),
|
||||
export: t("zookeeper.export"),
|
||||
utf8PreviewLossy: t("zookeeper.utf8PreviewLossy"),
|
||||
utf8PreviewUnavailable: t("zookeeper.utf8PreviewUnavailable"),
|
||||
createMode: t("zookeeper.createMode"),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cancelQuery: vi.fn(),
|
||||
executeMultiWithProgress: vi.fn(),
|
||||
openDataGenerateSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
cancelDatabaseExport: vi.fn(),
|
||||
cancelQuery: mocks.cancelQuery,
|
||||
cancelSqlFileExecution: vi.fn(),
|
||||
cancelTableExport: vi.fn(),
|
||||
cancelTransfer: vi.fn(),
|
||||
executeMultiWithProgress: mocks.executeMultiWithProgress,
|
||||
startTransfer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useDialogSources", () => ({
|
||||
openDataGenerateSession: mocks.openDataGenerateSession,
|
||||
}));
|
||||
|
||||
import { getDataGenerateSession, startDataGenerateSession } from "@/composables/useDataGenerateSession";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
|
||||
function successfulResult(): QueryResult[] {
|
||||
return [
|
||||
{
|
||||
columns: [],
|
||||
rows: [],
|
||||
affected_rows: 2,
|
||||
execution_time_ms: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function startSession() {
|
||||
return startDataGenerateSession(
|
||||
{
|
||||
connectionId: "connection-1",
|
||||
database: "app",
|
||||
prefillSchema: "public",
|
||||
databaseType: "postgres",
|
||||
label: "app.users",
|
||||
targets: [
|
||||
{
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
database: "app",
|
||||
targetRowCount: 2,
|
||||
isSample: false,
|
||||
columns: ["id"],
|
||||
rows: [[1], [2]],
|
||||
sql: "INSERT INTO users VALUES (1), (2);",
|
||||
statements: ["INSERT INTO users VALUES (1), (2);"],
|
||||
resolvedColumns: [
|
||||
{
|
||||
columnName: "id",
|
||||
dataType: "integer",
|
||||
rowCount: 2,
|
||||
generatorKey: "sequence",
|
||||
generatorParams: { startValue: 1, increment: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
options: {
|
||||
continueOnError: false,
|
||||
truncate: false,
|
||||
useTransaction: true,
|
||||
extendedInsert: true,
|
||||
timeoutSecs: 0,
|
||||
batchRows: 2,
|
||||
},
|
||||
},
|
||||
{ invalidateMetadataCache: vi.fn() },
|
||||
);
|
||||
}
|
||||
|
||||
function resetTracker() {
|
||||
const tracker = useExportTracker();
|
||||
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetTracker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetTracker();
|
||||
});
|
||||
|
||||
describe("data generation background sessions", () => {
|
||||
it("continues outside the dialog, reopens from the task entry, and cleans up after completion", async () => {
|
||||
let finishBatch!: (result: QueryResult[]) => void;
|
||||
mocks.executeMultiWithProgress.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<QueryResult[]>((resolve) => {
|
||||
finishBatch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const tracker = useExportTracker();
|
||||
const session = startSession();
|
||||
const task = tracker.tasks.value.find((candidate) => candidate.exportId === session.id)!;
|
||||
|
||||
await vi.waitFor(() => expect(mocks.executeMultiWithProgress).toHaveBeenCalledOnce());
|
||||
expect(session.status).toBe("running");
|
||||
expect(task.status).toBe("Running");
|
||||
|
||||
task.onOpen?.();
|
||||
expect(mocks.openDataGenerateSession).toHaveBeenCalledWith(session.id, {
|
||||
connectionId: "connection-1",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
});
|
||||
|
||||
finishBatch(successfulResult());
|
||||
await vi.waitFor(() => expect(session.status).toBe("completed"));
|
||||
expect(task.status).toBe("Done");
|
||||
expect(task.rowsExported).toBe(2);
|
||||
expect(session.results).toEqual([{ table: "users", total: 2, ok: 2, err: 0, error: undefined, cancelled: undefined }]);
|
||||
|
||||
tracker.removeTask(session.id);
|
||||
expect(getDataGenerateSession(session.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps cancel separate from minimize and publishes the cancelled terminal state", async () => {
|
||||
let failBatch!: (error: Error) => void;
|
||||
mocks.executeMultiWithProgress.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<QueryResult[]>((_resolve, reject) => {
|
||||
failBatch = reject;
|
||||
}),
|
||||
);
|
||||
mocks.cancelQuery.mockResolvedValueOnce(true);
|
||||
|
||||
const tracker = useExportTracker();
|
||||
const session = startSession();
|
||||
const task = tracker.tasks.value.find((candidate) => candidate.exportId === session.id)!;
|
||||
await vi.waitFor(() => expect(mocks.executeMultiWithProgress).toHaveBeenCalledOnce());
|
||||
|
||||
await tracker.cancelTask(session.id);
|
||||
expect(mocks.cancelQuery).toHaveBeenCalledWith(session.id);
|
||||
expect(session.status).toBe("cancelling");
|
||||
expect(task.status).toBe("Cancelling");
|
||||
|
||||
failBatch(new Error("Query canceled"));
|
||||
await vi.waitFor(() => expect(session.status).toBe("cancelled"));
|
||||
expect(task.status).toBe("Cancelled");
|
||||
expect(session.results[0]?.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("retains per-table failures in the resumable result", async () => {
|
||||
mocks.executeMultiWithProgress.mockResolvedValueOnce([
|
||||
{
|
||||
columns: ["Error"],
|
||||
rows: [["duplicate key"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
execution_error: true,
|
||||
} as QueryResult,
|
||||
]);
|
||||
|
||||
const tracker = useExportTracker();
|
||||
const session = startSession();
|
||||
const task = tracker.tasks.value.find((candidate) => candidate.exportId === session.id)!;
|
||||
|
||||
await vi.waitFor(() => expect(session.status).toBe("completed"));
|
||||
expect(task.status).toBe("Error");
|
||||
expect(task.errorMessage).toBe("duplicate key");
|
||||
expect(session.results[0]).toMatchObject({ table: "users", ok: 0, err: 2, error: "duplicate key" });
|
||||
});
|
||||
});
|
||||
+2
-6
@@ -3,7 +3,6 @@
|
||||
import { createApp, defineComponent, h, ref, computed } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridColumnFilters, type DataGridColumnFilterState, type DataGridLocalFilterDraft } from "@/composables/useDataGridColumnFilters";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
@@ -17,11 +16,6 @@ function mountColumnFilters(rows: CellValue[][]) {
|
||||
localFilterOpenColumn: ref<number | null>(null),
|
||||
localFilterSearch: ref(""),
|
||||
localFilterDraft: ref<DataGridLocalFilterDraft | null>(null),
|
||||
serverFilterLoading: ref(false),
|
||||
serverFilterError: ref(""),
|
||||
serverFilterOptions: ref<DataGridLocalFilterOption[]>([]),
|
||||
serverFilterLimited: ref(false),
|
||||
serverFilterValueByKey: ref(new Map()),
|
||||
serverColumnFilters: ref({}),
|
||||
};
|
||||
const result: QueryResult = { columns: ["status"], rows };
|
||||
@@ -36,6 +30,7 @@ function mountColumnFilters(rows: CellValue[][]) {
|
||||
getConnectionId: () => undefined,
|
||||
getSchema: () => undefined,
|
||||
getExecutionDatabase: () => "",
|
||||
scopeIdentity: computed(() => "test"),
|
||||
resolvedDatabaseType: computed(() => undefined),
|
||||
canUseWhereSearch: computed(() => false),
|
||||
canUseServerColumnFilter: computed(() => false),
|
||||
@@ -44,6 +39,7 @@ function mountColumnFilters(rows: CellValue[][]) {
|
||||
whereFilterInput: ref(""),
|
||||
getConnectionConfig: () => undefined,
|
||||
getIdentifierQuote: () => undefined,
|
||||
getGlobalQueryTimeoutSecs: () => undefined,
|
||||
getNewRows: () => [],
|
||||
getRowData: (row) => row,
|
||||
formatValue: (value) => String(value),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user